From b3070c0bdadf5ec42a8509c00a52ed0f48877f53 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 15:36:55 -0400 Subject: [PATCH] feat(scadabridge): wire AddZbTelemetry + /metrics in both composition roots --- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 18 +++++ .../SiteServiceRegistration.cs | 15 ++++ .../MetricsEndpointTests.cs | 79 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index fddb5f1d..73f84f7c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -21,6 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.SiteCallAudit; using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.Transport; +using ZB.MOM.WW.Telemetry; using Serilog; // SCADABRIDGE_CONFIG determines which role-specific config to load (Central or Site) @@ -248,6 +249,12 @@ try // All three are anonymous and use the canonical ZbHealthWriter JSON output. app.MapZbHealth(); + // Observability — mount the always-on Prometheus /metrics scrape endpoint. + // AddZbTelemetry (in SiteServiceRegistration.BindSharedOptions) wires the OTel + // Resource + standard instrumentation + Prometheus exporter; this exposes them. + // Requires endpoint routing (app.UseRouting() above). + app.MapZbMetrics(); + app.MapStaticAssets(); app.MapCentralUI(); app.MapInboundAPI(); @@ -304,6 +311,17 @@ try var app = builder.Build(); + // Endpoint routing middleware. The gRPC service mapping below and the + // /metrics scrape endpoint both run on endpoint routing, so UseRouting() + // must be present before the Map* calls on the site role. + app.UseRouting(); + + // Observability — mount the always-on Prometheus /metrics scrape endpoint. + // AddZbTelemetry (in SiteServiceRegistration.Configure → BindSharedOptions) + // wires the OTel Resource + standard instrumentation + Prometheus exporter; + // this exposes them on the site node too. + app.MapZbMetrics(); + // Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI app.MapGrpcService(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index 05b5f7ab..92cf8b89 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationService; using ZB.MOM.WW.ScadaBridge.SiteEventLogging; using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.StoreAndForward; +using ZB.MOM.WW.Telemetry; namespace ZB.MOM.WW.ScadaBridge.Host; @@ -114,5 +115,19 @@ public static class SiteServiceRegistration // writers so they can stamp the SourceNode column. Registered here in // shared bootstrap because every node (central + site) needs it. services.AddSingleton(); + + // Observability — shared ZB.MOM.WW.Telemetry. Registered in shared bootstrap so + // BOTH the central and site composition roots wire the OTel Resource (the + // service.name/site.id/node.role identity triple) + standard instrumentation + + // the always-on Prometheus exporter. Mount the /metrics scrape endpoint per role + // with app.MapZbMetrics(). The same `?? "central"` SiteId default Program.cs uses + // is applied here so the Resource attribute matches the log-enricher value. + // Meters left empty — application instruments are a deferred follow-on. + services.AddZbTelemetry(o => + { + o.ServiceName = "scadabridge"; + o.SiteId = config["ScadaBridge:Node:SiteId"] ?? "central"; + o.NodeRole = config["ScadaBridge:Node:Role"]; + }); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs new file mode 100644 index 00000000..90118bf4 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Observability adoption: verifies the shared ZB.MOM.WW.Telemetry Prometheus +/// scrape endpoint (/metrics, mounted by app.MapZbMetrics()) is wired into the +/// Central composition root. AddZbTelemetry (registered in +/// ) always wires the Prometheus +/// exporter, so the endpoint returns the Prometheus exposition format regardless of DB / +/// cluster state. This is a pure route assertion — it requires no database, LDAP, or formed +/// Akka cluster. The Central-role factory bootstrap mirrors . +/// +public class MetricsEndpointTests : IDisposable +{ + private readonly List _disposables = new(); + + public MetricsEndpointTests() + { + // Host-003: connection strings are externalised; supply them via env vars. + _disposables.Add(new CentralDbTestEnvironment()); + } + + public void Dispose() + { + foreach (var d in _disposables) + { + try { d.Dispose(); } catch { /* best effort */ } + } + } + + private WebApplicationFactory CreateCentralFactory() + { + var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaBridge:Node:NodeHostname"] = "localhost", + ["ScadaBridge:Node:RemotingPort"] = "0", + ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551", + ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", + ["ScadaBridge:Database:SkipMigrations"] = "true", + }); + }); + builder.UseSetting("ScadaBridge:Node:Role", "Central"); + builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true"); + }); + _disposables.Add(factory); + return factory; + } + + [Fact] + public async Task Metrics_Endpoint_IsMapped() + { + var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + var factory = CreateCentralFactory(); + var client = factory.CreateClient(); + _disposables.Add(client); + + var response = await client.GetAsync("/metrics"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("# ", body); // Prometheus exposition (HELP/TYPE comments) + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv); + } + } +}