diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs new file mode 100644 index 0000000..54442c3 --- /dev/null +++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry/ZbTelemetryExtensions.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace ZB.MOM.WW.Telemetry; + +/// +/// Extension point for configuring the OpenTelemetry metrics + traces bootstrap on an +/// (or directly on an ). +/// Wires the shared Resource, standard instrumentation, the app's own Meters and +/// ActivitySources, and the selected exporter. Does NOT configure Serilog. +/// +public static class ZbTelemetryExtensions +{ + /// + /// Configures the OpenTelemetry MeterProvider and TracerProvider with the shared Resource, + /// standard instrumentation (ASP.NET Core, HttpClient, gRPC client, runtime, process), the + /// app's own Meters and ActivitySources, and the selected exporter. + /// + /// The host application builder. + /// Populates the . + public static IHostApplicationBuilder AddZbTelemetry( + this IHostApplicationBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddZbTelemetry(BuildOptions(configure)); + return builder; + } + + /// + /// overload for contexts where + /// is not available. Requires the caller to supply a + /// pre-built . + /// + /// The service collection. + /// The fully-populated telemetry options. + public static IServiceCollection AddZbTelemetry( + this IServiceCollection services, + ZbTelemetryOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(options); + + services.AddOpenTelemetry() + .ConfigureResource(rb => ZbResource.Configure(rb, options)) + .WithMetrics(metrics => + { + foreach (var meter in options.Meters) + { + metrics.AddMeter(meter); + } + + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation(); + + ApplyMetricsExporter(metrics, options); + }) + .WithTracing(tracing => + { + foreach (var source in options.ActivitySources) + { + tracing.AddSource(source); + } + + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddGrpcClientInstrumentation(); + + ApplyTracingExporter(tracing, options); + }); + + return services; + } + + /// + /// IServiceCollection overload that accepts a configure delegate (convenience for callers + /// that only have an but prefer the lambda form). + /// + /// The service collection. + /// Populates the . + public static IServiceCollection AddZbTelemetry( + this IServiceCollection services, + Action configure) => + services.AddZbTelemetry(BuildOptions(configure)); + + private static ZbTelemetryOptions BuildOptions(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var options = new ZbTelemetryOptions(); + configure(options); + return options; + } + + private static void ApplyMetricsExporter(MeterProviderBuilder metrics, ZbTelemetryOptions options) + { + switch (options.Exporter) + { + case ZbExporter.Otlp: + metrics.AddOtlpExporter(o => ConfigureOtlp(o, options)); + break; + case ZbExporter.Prometheus: + default: + metrics.AddPrometheusExporter(); + break; + } + } + + private static void ApplyTracingExporter(TracerProviderBuilder tracing, ZbTelemetryOptions options) + { + // Prometheus is metrics-only; traces have no Prometheus path. Only OTLP exports traces. + if (options.Exporter == ZbExporter.Otlp) + { + tracing.AddOtlpExporter(o => ConfigureOtlp(o, options)); + } + } + + private static void ConfigureOtlp( + OpenTelemetry.Exporter.OtlpExporterOptions otlp, + ZbTelemetryOptions options) + { + if (!string.IsNullOrEmpty(options.OtlpEndpoint)) + { + otlp.Endpoint = new Uri(options.OtlpEndpoint); + } + } +} diff --git a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs new file mode 100644 index 0000000..7aee2ce --- /dev/null +++ b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Tests/AddZbTelemetryTests.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using ZB.MOM.WW.Telemetry; + +namespace ZB.MOM.WW.Telemetry.Tests; + +public sealed class AddZbTelemetryTests +{ + [Fact] + public void AddZbTelemetry_ExportsAppMeter_WithSharedResource() + { + // 1.15.x note: AddInMemoryExporter moved out of the core OpenTelemetry assembly into a + // separate OpenTelemetry.Exporter.InMemory package (not referenced here). We attach a + // BaseExporter directly instead — it both collects metric names and exposes the + // MeterProvider Resource via ParentProvider.GetResource(). + var capture = new CapturingMetricExporter(); + + var builder = WebApplication.CreateBuilder(); + builder.AddZbTelemetry(o => + { + o.ServiceName = "t"; + o.SiteId = "site-test"; + o.NodeRole = "central"; + o.Meters = ["Test.Meter"]; + }); + + // Compose a capturing reader onto the pipeline AddZbTelemetry already registered. + builder.Services.ConfigureOpenTelemetryMeterProvider(b => + b.AddReader(new PeriodicExportingMetricReader(capture) + { + TemporalityPreference = MetricReaderTemporalityPreference.Cumulative, + })); + + // Create the meter + instrument BEFORE the provider is built so the MeterProvider's + // listener subscribes to it during construction. + using var meter = new Meter("Test.Meter"); + var counter = meter.CreateCounter("test.events.count"); + + using var app = builder.Build(); + + var meterProvider = app.Services.GetRequiredService(); + counter.Add(1); + meterProvider.ForceFlush(); + + // The app's meter was registered and its instrument was collected through the pipeline. + Assert.Contains("test.events.count", capture.MetricNames); + + // The exported metric carries the shared Resource (identical to ZbResource.Build). + Assert.NotNull(capture.CapturedResource); + var attrs = capture.CapturedResource!.Attributes.ToDictionary(a => a.Key, a => a.Value); + Assert.Equal("t", attrs["service.name"]); + Assert.Equal("ZB.MOM.WW", attrs["service.namespace"]); + Assert.Equal("site-test", attrs["site.id"]); + Assert.Equal("central", attrs["node.role"]); + Assert.Equal(Environment.MachineName, attrs["host.name"]); + } + + /// + /// Collects exported metric names and captures the MeterProvider Resource on first export so + /// the test can assert the pipeline wired both the app meter and the shared Resource. + /// + private sealed class CapturingMetricExporter : BaseExporter + { + public List MetricNames { get; } = []; + public Resource? CapturedResource { get; private set; } + + public override ExportResult Export(in Batch batch) + { + CapturedResource ??= ParentProvider?.GetResource(); + foreach (var metric in batch) + { + MetricNames.Add(metric.Name); + } + + return ExportResult.Success; + } + } +}