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();
+}