using System.Diagnostics.Metrics; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; 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 { // Fix #2: empty ServiceName must throw ArgumentException -------------------------- [Fact] public void AddZbTelemetry_Throws_WhenServiceNameIsEmpty() { var builder = WebApplication.CreateBuilder(); var ex = Assert.Throws(() => builder.AddZbTelemetry(o => { o.ServiceName = ""; // explicitly empty })); Assert.Equal("configure", ex.ParamName); } [Fact] public void AddZbTelemetry_Throws_WhenServiceNameIsWhitespace() { var builder = WebApplication.CreateBuilder(); var ex = Assert.Throws(() => builder.AddZbTelemetry(o => { o.ServiceName = " "; })); Assert.Equal("configure", ex.ParamName); } // Telemetry-006: malformed/missing OtlpEndpoint must fail fast with a clear, named error // instead of a late UriFormatException deep inside exporter construction. [Fact] public void AddZbTelemetry_Throws_WhenOtlpExporterHasMalformedEndpoint() { var builder = WebApplication.CreateBuilder(); var ex = Assert.Throws(() => builder.AddZbTelemetry(o => { o.ServiceName = "telemetry-test"; o.Exporter = ZbExporter.Otlp; o.OtlpEndpoint = "not a uri"; // missing scheme — not an absolute URI })); Assert.Equal("configure", ex.ParamName); Assert.Contains("OtlpEndpoint", ex.Message); } [Fact] public void AddZbTelemetry_Throws_WhenOtlpExporterHasNoEndpoint() { var builder = WebApplication.CreateBuilder(); var ex = Assert.Throws(() => builder.AddZbTelemetry(o => { o.ServiceName = "telemetry-test"; o.Exporter = ZbExporter.Otlp; // OtlpEndpoint left null })); Assert.Equal("configure", ex.ParamName); Assert.Contains("OtlpEndpoint", ex.Message); } [Fact] public void AddZbTelemetry_DoesNotValidateEndpoint_WhenExporterIsPrometheus() { // A stray (even malformed) endpoint is harmless under the Prometheus exporter and must not // be validated — it is ignored. var builder = WebApplication.CreateBuilder(); var ex = Record.Exception(() => builder.AddZbTelemetry(o => { o.ServiceName = "telemetry-test"; o.Exporter = ZbExporter.Prometheus; o.OtlpEndpoint = "not a uri"; })); Assert.Null(ex); } // Fix #1: Prometheus coexists with OTLP — /metrics must still serve under Otlp exporter [Fact] public async Task AddZbTelemetry_OtlpExporter_StillServesPrometheusEndpoint() { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.AddZbTelemetry(o => { o.ServiceName = "telemetry-test"; o.Exporter = ZbExporter.Otlp; // A well-formed endpoint is required under the Otlp exporter (Telemetry-006); the // exporter is registered but won't connect anywhere in the test. We are only verifying // Prometheus remains present. o.OtlpEndpoint = "http://localhost:4317"; o.Meters = ["Test.OtlpCoexist.Meter"]; }); await using var app = builder.Build(); app.MapZbMetrics(); await app.StartAsync(); var address = app.Urls.First(); using var client = new HttpClient { BaseAddress = new Uri(address) }; var response = await client.GetAsync("/metrics"); Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType); await app.StopAsync(); } // Existing test --------------------------------------------------------------- [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; } } }