From a633005f4f826e662d09fa47359bced17ccd6acf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 21:13:25 -0500 Subject: [PATCH] feat(telemetry): Telemetry 0.3.0 - service.version and logs over OTLP (scadaproj#55) Pins ZB.MOM.WW.Telemetry(.Serilog) 0.2.0 -> 0.3.0. ScadaBridgeTelemetryConfig is now the one place both the AddZbTelemetry registration and the pre-host AddZbSerilog logger fill their options, so ScadaBridge:Telemetry:Exporter=Otlp sends logs to the same collector as metrics and traces, with the same Resource. The pre-host container has no AddZbTelemetry registration for the library's log sink to follow, which is why the exporter is passed to the logger explicitly. site.id / node.role are unchanged (still ScadaBridge:Node:SiteId ?? central / ScadaBridge:Node:Role, applied last so a Telemetry-section key cannot make them diverge from the log enrichers); service.version now defaults to the entry assembly's informational version. Host.Tests 699/699 incl. ScadaBridgeTelemetryConfigTests. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- Directory.Packages.props | 4 +- .../ScadaBridgeLogging.cs | 16 ++-- .../ScadaBridgeTelemetryConfig.cs | 42 ++++++++++ .../SiteServiceRegistration.cs | 12 +-- .../ScadaBridgeTelemetryConfigTests.cs | 81 +++++++++++++++++++ 5 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeTelemetryConfig.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryConfigTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a695772c..8b20addb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -106,8 +106,8 @@ - - + + diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeLogging.cs b/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeLogging.cs index 9ee7cf7b..9cee0aea 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeLogging.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeLogging.cs @@ -18,8 +18,10 @@ namespace ZB.MOM.WW.ScadaBridge.Host; /// Serilog section (the console and file sinks, output template, file path and rolling /// interval all stay configuration-driven in appsettings.json), the SiteId / /// NodeRole / NodeHostname identity enrichers, the shared TraceContextEnricher -/// and the (inert here, no ILogRedactor is registered) redaction seam. No OTLP log sink is -/// added: the telemetry options passed here never select the OTLP exporter, as before. +/// and the (inert here, no ILogRedactor is registered) redaction seam. With +/// ScadaBridge:Telemetry:Exporter=Otlp (Telemetry 0.3.0+, scadaproj#55) it also adds the OTLP +/// log sink, to the same endpoint and with the same Resource as metrics and traces +/// (); ScadaBridge:Telemetry:ExportLogs=false keeps logs local. /// /// /// Minimum level (Host-011). ScadaBridge:Logging:MinimumLevel is the single source of @@ -127,12 +129,10 @@ public static class ScadaBridgeLogging ArgumentNullException.ThrowIfNull(warningWriter); return builder.AddZbSerilog( - telemetry => - { - telemetry.ServiceName = ServiceName; - telemetry.SiteId = siteId; - telemetry.NodeRole = nodeRole; - }, + // The same options the AddZbTelemetry registration builds — including + // ScadaBridge:Telemetry:Exporter/OtlpEndpoint, so Exporter=Otlp sends logs to the + // collector too (scadaproj#55; this pre-host container has no AddZbTelemetry to follow). + telemetry => ScadaBridgeTelemetryConfig.Apply(telemetry, builder.Configuration, siteId, nodeRole), serilog => { serilog.MinimumLevelGovernor = configuration => GovernMinimumLevel(configuration, warningWriter); diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeTelemetryConfig.cs b/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeTelemetryConfig.cs new file mode 100644 index 00000000..1a96bdff --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/ScadaBridgeTelemetryConfig.cs @@ -0,0 +1,42 @@ +using ZB.MOM.WW.Telemetry; + +namespace ZB.MOM.WW.ScadaBridge.Host; + +/// +/// The one place ScadaBridge fills , shared by the +/// AddZbTelemetry registration (metrics + traces) and the pre-host AddZbSerilog +/// logger (logs), so the three signals carry the same Resource and go to the same endpoint. +/// +/// +/// The logger is built on its own pre-host container (see ), where +/// no AddZbTelemetry registration exists for Telemetry 0.3.0's OTLP log sink to follow, so the +/// exporter settings are passed to AddZbSerilog explicitly (scadaproj#55). +/// +internal static class ScadaBridgeTelemetryConfig +{ + /// The operator section: Exporter, OtlpEndpoint, ServiceVersion, + /// ExportLogs, ResourceAttributes:*. + internal const string SectionName = "ScadaBridge:Telemetry"; + + /// + /// Applies the service name, the ScadaBridge:Telemetry keys and then the node identity. + /// / are applied LAST so the Resource always + /// matches the SiteId/NodeRole log enrichers (both come from ScadaBridge:Node); + /// a ScadaBridge:Telemetry:SiteId/:NodeRole key cannot make them diverge. + /// service.version defaults from the entry assembly (the git SHA). + /// + /// The options to fill. + /// The application configuration. + /// The node's site id (ScadaBridge:Node:SiteId, default central). + /// The node's role (ScadaBridge:Node:Role). + internal static void Apply(ZbTelemetryOptions options, IConfiguration configuration, string? siteId, string? nodeRole) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(configuration); + + options.ServiceName = ScadaBridgeLogging.ServiceName; + options.ApplyConfiguration(configuration.GetSection(SectionName)); + options.SiteId = siteId; + options.NodeRole = nodeRole; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index 2bbe0073..4259e0d4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -384,15 +384,11 @@ public static class SiteServiceRegistration // wired by follow-on tasks (the instruments are no-op until a listener attaches). services.AddZbTelemetry(o => { - o.ServiceName = "scadabridge"; - o.SiteId = config["ScadaBridge:Node:SiteId"] ?? "central"; - o.NodeRole = config["ScadaBridge:Node:Role"]; + // Shared with the logger (ScadaBridgeLogging) so logs carry the same Resource and go to + // the same OTLP endpoint (scadaproj#55). + ScadaBridgeTelemetryConfig.Apply( + o, config, config["ScadaBridge:Node:SiteId"] ?? "central", config["ScadaBridge:Node:Role"]); o.Meters = ObservedMeters; - if (Enum.TryParse(config["ScadaBridge:Telemetry:Exporter"], ignoreCase: true, out var exporter)) - o.Exporter = exporter; - var otlp = config["ScadaBridge:Telemetry:OtlpEndpoint"]; - if (!string.IsNullOrWhiteSpace(otlp)) - o.OtlpEndpoint = otlp; }); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryConfigTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryConfigTests.cs new file mode 100644 index 00000000..6648713b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryConfigTests.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.ScadaBridge.Host; +using ZB.MOM.WW.Telemetry; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// scadaproj#55: the logger and the AddZbTelemetry registration fill their options from one place, +/// so ScadaBridge:Telemetry:Exporter=Otlp reaches the pre-host logger (OTLP log sink) as well as +/// metrics and traces, and the node identity is unchanged. +/// +public class ScadaBridgeTelemetryConfigTests +{ + private static IConfiguration Config(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + /// Exporter and endpoint come from ScadaBridge:Telemetry; identity from the arguments. + [Fact] + public void Apply_CarriesExporterAndIdentity() + { + var options = new ZbTelemetryOptions(); + + ScadaBridgeTelemetryConfig.Apply(options, Config(new() + { + ["ScadaBridge:Telemetry:Exporter"] = "Otlp", + ["ScadaBridge:Telemetry:OtlpEndpoint"] = "http://collector:4317", + }), siteId: "site-a", nodeRole: "Site"); + + Assert.Equal("scadabridge", options.ServiceName); + Assert.Equal(ZbExporter.Otlp, options.Exporter); + Assert.Equal("http://collector:4317", options.OtlpEndpoint); + Assert.Equal("site-a", options.SiteId); + Assert.Equal("Site", options.NodeRole); + Assert.Null(options.ServiceVersion); // library default: entry-assembly informational version + } + + /// A Telemetry-section SiteId/NodeRole cannot make the Resource diverge from the log enrichers. + [Fact] + public void Apply_NodeIdentityWinsOverTelemetrySection() + { + var options = new ZbTelemetryOptions(); + + ScadaBridgeTelemetryConfig.Apply(options, Config(new() + { + ["ScadaBridge:Telemetry:SiteId"] = "other", + ["ScadaBridge:Telemetry:NodeRole"] = "other", + }), siteId: "central", nodeRole: "Central"); + + Assert.Equal("central", options.SiteId); + Assert.Equal("Central", options.NodeRole); + Assert.Equal(ZbExporter.Prometheus, options.Exporter); + } + + /// The OTLP logger path builds (validation passes) and still logs to the configured sinks. + [Fact] + public void CreateLogger_WithOtlpExporter_Builds() + { + var config = Config(new() + { + ["ScadaBridge:Telemetry:Exporter"] = "Otlp", + ["ScadaBridge:Telemetry:OtlpEndpoint"] = "http://127.0.0.1:1", + }); + + using var logger = ScadaBridgeLogging.CreateLogger(config, "Site", "site-a", "node-a", TextWriter.Null, additionalConfiguration: null); + logger.Information("otlp wiring smoke test"); + } + + /// A scheme-less endpoint now fails the logger at startup (Telemetry 0.3.0 validation). + [Fact] + public void CreateLogger_WithSchemelessEndpoint_Throws() + { + var config = Config(new() + { + ["ScadaBridge:Telemetry:Exporter"] = "Otlp", + ["ScadaBridge:Telemetry:OtlpEndpoint"] = "collector:4317", + }); + + Assert.Throws(() => + ScadaBridgeLogging.CreateLogger(config, "Site", "site-a", "node-a", TextWriter.Null, additionalConfiguration: null)); + } +}