From 412da27c5fb7e440e4a75d4f8e82e0e5b9832411 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 21:13:23 -0500 Subject: [PATCH] feat(telemetry): Telemetry 0.3.0 - service.version, site.id/node.role, logs over OTLP (scadaproj#55) Pins ZB.MOM.WW.Telemetry(.Serilog) 0.1.0 -> 0.3.0. The OTel Resource now names the node: site.id = its application Cluster id (the cluster- role suffix, omitted on a legacy node), node.role = its plain roles in canonical order (admin / driver / admin,driver), service.version = the entry assembly's informational version (the git SHA). Program passes its resolved EffectiveRoles. The whole OtOpcUa:Telemetry section goes through the shared ApplyConfiguration, so SiteId / NodeRole / ServiceVersion / ResourceAttributes:* are operator overrides, and Exporter=Otlp now also sends the Serilog logs to the same collector. ObservabilityResourceIdentityTests cover the mapping and the built container's Resource. Host.Tests 288/288, Host.IntegrationTests 325/327 (2 skipped). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- Directory.Packages.props | 4 +- .../Observability/ObservabilityExtensions.cs | 73 +++++++++++++++---- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 2 +- .../ObservabilityResourceIdentityTests.cs | 71 ++++++++++++++++++ 4 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityResourceIdentityTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 11ebc01be..011b90e8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -228,8 +228,8 @@ - - + + 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 6cb3d79f8..bcd7acdd2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using ZB.MOM.WW.LocalDb.Replication; +using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy; using ZB.MOM.WW.Telemetry; @@ -46,23 +47,67 @@ public static class ObservabilityExtensions /// Configuration read for the opt-in OTLP exporter. OtOpcUa:Telemetry:Exporter /// (parsed case-insensitively to ) switches to OTLP when set to /// Otlp; OtOpcUa:Telemetry:OtlpEndpoint sets the OTLP endpoint. With no - /// config the exporter stays Prometheus (the default). + /// config the exporter stays Prometheus (the default). Since Telemetry 0.3.0 the same switch + /// also sends the host's Serilog logs to that endpoint (scadaproj#55). + /// + /// + /// The node's resolved roles (Program's single role authority). resolves + /// them from . They feed the Resource identity — see + /// . /// /// The service collection, for chaining. - public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddOtOpcUaObservability( + this IServiceCollection services, + IConfiguration configuration, + EffectiveRoles? roles = null) { - return services.AddZbTelemetry(o => - { - o.ServiceName = "otopcua"; - // 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"]; - if (!string.IsNullOrWhiteSpace(otlp)) - o.OtlpEndpoint = otlp; - }); + roles ??= EffectiveRoles.Resolve(configuration); + return services.AddZbTelemetry(o => ConfigureTelemetry(o, configuration, roles)); + } + + /// + /// Populates the shared telemetry options. Resource identity (scadaproj#55): + /// site.id = the node's application Cluster id (the suffix of its + /// cluster-<ClusterId> role — one mesh per cluster, so it names the pair the node + /// belongs to; omitted on a legacy node without one), node.role = its plain roles in + /// canonical order (admin, driver, admin,driver, …; omitted when none). + /// service.version defaults from the entry assembly's informational version (the git SHA). + /// Operator keys under OtOpcUa:Telemetry are applied last (see + /// ). + /// + /// The options to populate. + /// The host configuration (exporter keys). + /// The node's resolved roles. + public static void ConfigureTelemetry(ZbTelemetryOptions o, IConfiguration configuration, EffectiveRoles roles) + { + ArgumentNullException.ThrowIfNull(o); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(roles); + + o.ServiceName = "otopcua"; + o.SiteId = roles.ClusterId; + o.NodeRole = NodeRoleOf(roles); + // Strict allowlists — see ObservedMeters / ObservedActivitySources. + o.Meters = ObservedMeters; + o.ActivitySources = ObservedActivitySources; + // OtOpcUa:Telemetry:{Exporter, OtlpEndpoint, ServiceVersion, SiteId, NodeRole, ExportLogs, + // ResourceAttributes:*} — an explicit SiteId/NodeRole there overrides the role-derived values. + o.ApplyConfiguration(configuration.GetSection("OtOpcUa:Telemetry")); + } + + /// The plain (non-cluster) roles, lower-case, sorted and comma-joined; null when none. + /// The node's resolved roles. + /// For example admin,driver, or . + public static string? NodeRoleOf(EffectiveRoles roles) + { + ArgumentNullException.ThrowIfNull(roles); + var plain = roles.Gossiped + .Where(r => !RoleParser.IsClusterRole(r)) + .Select(r => r.ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + return plain.Length == 0 ? null : string.Join(",", plain); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 0d5d88ab6..b480fa6b2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -615,7 +615,7 @@ builder.Services.AddOtOpcUaSecrets(builder.Configuration); // probing it would throw when the readiness endpoint resolves the factory. hasDriver adds the // opcua-endpoint probe (ready + active): a driver node whose SDK failed to start is Unhealthy, not green. builder.Services.AddOtOpcUaHealth(hasAdmin, hasDriver); -builder.Services.AddOtOpcUaObservability(builder.Configuration); +builder.Services.AddOtOpcUaObservability(builder.Configuration, effectiveRoles); // gRPC server plumbing shared by three endpoints: the LocalDb passive sync endpoint (driver-role, // mapped below on hasDriver && syncListenPort > 0), the ConfigServe artifact endpoint (admin-role, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityResourceIdentityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityResourceIdentityTests.cs new file mode 100644 index 000000000..a75bd1444 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ObservabilityResourceIdentityTests.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Trace; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Cluster; +using ZB.MOM.WW.OtOpcUa.Host.Observability; +using ZB.MOM.WW.Telemetry; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// scadaproj#55 — the OTel Resource carries site.id (the node's application Cluster id), +/// node.role (its plain roles) and service.version (entry-assembly default), so two +/// OtOpcUa nodes are distinguishable by more than service.instance.id. +/// +public sealed class ObservabilityResourceIdentityTests +{ + private static IConfiguration EmptyConfig() => new ConfigurationBuilder().Build(); + + [Fact] + public void FusedNode_ReportsClusterIdAndCanonicalRoles() + { + var roles = EffectiveRoles.Resolve(["driver", "cluster-SITE-A", "admin"], envRoles: null); + var o = new ZbTelemetryOptions(); + + ObservabilityExtensions.ConfigureTelemetry(o, EmptyConfig(), roles); + + o.SiteId.ShouldBe("SITE-A"); + o.NodeRole.ShouldBe("admin,driver"); + o.ServiceVersion.ShouldBeNull(); // null = the library's entry-assembly default + } + + [Fact] + public void LegacyNodeWithoutClusterRole_OmitsSiteId() + { + var roles = EffectiveRoles.Resolve(["driver"], envRoles: null); + var o = new ZbTelemetryOptions(); + + ObservabilityExtensions.ConfigureTelemetry(o, EmptyConfig(), roles); + + o.SiteId.ShouldBeNull(); + o.NodeRole.ShouldBe("driver"); + } + + [Fact] + public void NoRoles_OmitsNodeRole() + { + ObservabilityExtensions.NodeRoleOf(EffectiveRoles.Resolve(null, null)).ShouldBeNull(); + } + + [Fact] + public void BuiltContainer_ResourceCarriesIdentityAndVersion() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + builder.Services.AddOtOpcUaObservability( + builder.Configuration, EffectiveRoles.Resolve(["admin", "driver", "cluster-MAIN"], null)); + using var app = builder.Build(); + + var attributes = app.Services.GetRequiredService().GetResource().Attributes + .ToDictionary(a => a.Key, a => a.Value); + + attributes["service.name"].ShouldBe("otopcua"); + attributes["site.id"].ShouldBe("MAIN"); + attributes["node.role"].ShouldBe("admin,driver"); + attributes["service.version"].ShouldBe(ZbResource.DefaultServiceVersion); + ((string)attributes["service.version"]).ShouldNotBeNullOrWhiteSpace(); + } +}