diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverTelemetryNames.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverTelemetryNames.cs new file mode 100644 index 000000000..ec9877cbf --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverTelemetryNames.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; + +/// +/// The Galaxy driver's OpenTelemetry instrument names, public so the host can hand them to +/// AddZbTelemetry. ZbTelemetryOptions.Meters and .ActivitySources are +/// STRICT allow-lists: a name missing there is silently never exported — the driver's +/// galaxy.* spans and galaxy.events.* counters were dropped this way until +/// scadaproj#13 exercised the OTLP path end to end. +/// +public static class GalaxyDriverTelemetryNames +{ + /// The meter the event pump's galaxy.events.* counters are created on. + public const string MeterName = "ZB.MOM.WW.OtOpcUa.Driver.Galaxy"; + + /// The activity source the traced gateway decorators open galaxy.* spans on. + public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa.Driver.Galaxy"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs index 4617cb2c6..40dc34254 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/EventPump.cs @@ -34,7 +34,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; /// internal sealed class EventPump : IAsyncDisposable { - public const string MeterName = "ZB.MOM.WW.OtOpcUa.Driver.Galaxy"; + public const string MeterName = GalaxyDriverTelemetryNames.MeterName; private const int DefaultChannelCapacity = 50_000; // Single static meter so a host-level MeterListener catches all pump instances. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyTelemetry.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyTelemetry.cs index d0e2dcf12..d45ebe4e0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyTelemetry.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyTelemetry.cs @@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; /// internal static class GalaxyTelemetry { - public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa.Driver.Galaxy"; + public const string ActivitySourceName = GalaxyDriverTelemetryNames.ActivitySourceName; public static readonly ActivitySource ActivitySource = new(ActivitySourceName); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs index 171d95835..6cb3d79f8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.OtOpcUa.Commons.Observability; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy; using ZB.MOM.WW.Telemetry; namespace ZB.MOM.WW.OtOpcUa.Host.Observability; @@ -14,6 +15,31 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Observability; /// public static class ObservabilityExtensions { + /// + /// Every meter the host exports. ZbTelemetryOptions.Meters is a STRICT allowlist (no + /// wildcard): the LocalDb replication meter publishes its localdb.sync.* / + /// localdb.oplog.depth series on driver nodes, and the in-process Galaxy driver its + /// galaxy.events.* counters — each is silently absent from /metrics and OTLP unless + /// named here. Adding a meter anywhere in the product means adding it here. + /// + public static readonly string[] ObservedMeters = + [ + OtOpcUaTelemetry.MeterName, + LocalDbMetrics.MeterName, + GalaxyDriverTelemetryNames.MeterName, + ]; + + /// + /// Every activity source the host exports over OTLP. Also a strict allowlist: the Galaxy + /// driver's galaxy.* gateway-call spans were dropped until scadaproj#13 because its source + /// was missing here. + /// + public static readonly string[] ObservedActivitySources = + [ + OtOpcUaTelemetry.ActivitySourceName, + GalaxyDriverTelemetryNames.ActivitySourceName, + ]; + /// Adds OtOpcUa observability (metrics and tracing) to the service collection. /// The service collection to add observability services to. /// @@ -28,12 +54,9 @@ public static class ObservabilityExtensions return services.AddZbTelemetry(o => { o.ServiceName = "otopcua"; - // o.Meters is a STRICT allowlist — ZbTelemetry adds only the named meters, with no - // wildcard. The LocalDb replication engine publishes under its own meter name, so - // without this entry its localdb.sync.* / localdb.oplog.depth series are silently absent - // from /metrics on driver nodes (the exact omission a ScadaBridge live gate caught). - o.Meters = [OtOpcUaTelemetry.MeterName, LocalDbMetrics.MeterName]; - o.ActivitySources = [OtOpcUaTelemetry.ActivitySourceName]; + // Strict allowlists — see ObservedMeters / ObservedActivitySources. + o.Meters = ObservedMeters; + o.ActivitySources = ObservedActivitySources; if (Enum.TryParse(configuration["OtOpcUa:Telemetry:Exporter"], ignoreCase: true, out var exporter)) o.Exporter = exporter; var otlp = configuration["OtOpcUa:Telemetry:OtlpEndpoint"]; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityAllowlistTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityAllowlistTests.cs new file mode 100644 index 000000000..c3fa072e5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityAllowlistTests.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LocalDb.Replication; +using ZB.MOM.WW.OtOpcUa.Commons.Observability; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy; +using ZB.MOM.WW.OtOpcUa.Host.Observability; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// scadaproj#13 — ZbTelemetryOptions.Meters / .ActivitySources are strict allowlists, +/// so a meter or activity source the host forgets to name is silently never exported (the Galaxy +/// driver's galaxy.* spans and galaxy.events.* counters were). These tests build the +/// real container, start its +/// providers, and assert on the listener side: a span from a named source is sampled, and an +/// instrument on a named meter is enabled. An unregistered name is the negative control proving +/// the assertion can fail. +/// +public sealed class ObservabilityAllowlistTests : IAsyncDisposable +{ + private readonly WebApplication _app; + + public ObservabilityAllowlistTests() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + builder.Services.AddOtOpcUaObservability(builder.Configuration); + _app = builder.Build(); + // Resolving the providers is what attaches their ActivityListener / MeterListener. + _app.Services.GetRequiredService(); + _app.Services.GetRequiredService(); + } + + [Fact] + public void ObservedLists_NameTheGalaxyDriverMeterAndSource() + { + ObservabilityExtensions.ObservedMeters.ShouldBe( + [OtOpcUaTelemetry.MeterName, LocalDbMetrics.MeterName, GalaxyDriverTelemetryNames.MeterName], + ignoreOrder: true); + ObservabilityExtensions.ObservedActivitySources.ShouldBe( + [OtOpcUaTelemetry.ActivitySourceName, GalaxyDriverTelemetryNames.ActivitySourceName], + ignoreOrder: true); + } + + [Theory] + [InlineData(OtOpcUaTelemetry.ActivitySourceName)] + [InlineData(GalaxyDriverTelemetryNames.ActivitySourceName)] + public void SpanFromAnObservedSource_IsRecorded(string sourceName) + { + using var source = new ActivitySource(sourceName); + using var activity = source.StartActivity("allowlist-probe"); + activity.ShouldNotBeNull(); + activity.IsAllDataRequested.ShouldBeTrue(); + } + + [Fact] + public void SpanFromAnUnregisteredSource_IsDropped_NegativeControl() + { + using var source = new ActivitySource("ZB.MOM.WW.OtOpcUa.Unregistered." + Guid.NewGuid().ToString("N")); + source.StartActivity("allowlist-probe").ShouldBeNull(); + } + + [Theory] + [InlineData(OtOpcUaTelemetry.MeterName)] + [InlineData(LocalDbMetrics.MeterName)] + [InlineData(GalaxyDriverTelemetryNames.MeterName)] + public void InstrumentOnAnObservedMeter_IsEnabled(string meterName) + { + using var meter = new Meter(meterName); + var counter = meter.CreateCounter("allowlist.probe." + Guid.NewGuid().ToString("N")); + counter.Enabled.ShouldBeTrue(); + } + + [Fact] + public void InstrumentOnAnUnregisteredMeter_IsNotEnabled_NegativeControl() + { + using var meter = new Meter("ZB.MOM.WW.OtOpcUa.Unregistered." + Guid.NewGuid().ToString("N")); + meter.CreateCounter("allowlist.probe").Enabled.ShouldBeFalse(); + } + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +}