feat(telemetry): Telemetry 0.3.0 - service.version, site.id/node.role, logs over OTLP (scadaproj#55)
v2-ci / unit-tests (push) Has been cancelled

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-<ClusterId> 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
This commit is contained in:
Joseph Doherty
2026-11-12 21:13:23 -05:00
parent db86b996b8
commit 412da27c5f
4 changed files with 133 additions and 17 deletions
+2 -2
View File
@@ -228,8 +228,8 @@
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.3.7" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.4.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.4.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
@@ -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. <c>OtOpcUa:Telemetry:Exporter</c>
/// (parsed case-insensitively to <see cref="ZbExporter"/>) switches to OTLP when set to
/// <c>Otlp</c>; <c>OtOpcUa:Telemetry:OtlpEndpoint</c> 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).
/// </param>
/// <param name="roles">
/// The node's resolved roles (Program's single role authority). <see langword="null"/> resolves
/// them from <paramref name="configuration"/>. They feed the Resource identity — see
/// <see cref="ConfigureTelemetry"/>.
/// </param>
/// <returns>The service collection, for chaining.</returns>
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<ZbExporter>(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));
}
/// <summary>
/// Populates the shared telemetry options. Resource identity (scadaproj#55):
/// <c>site.id</c> = the node's application <c>Cluster</c> id (the suffix of its
/// <c>cluster-&lt;ClusterId&gt;</c> role — one mesh per cluster, so it names the pair the node
/// belongs to; omitted on a legacy node without one), <c>node.role</c> = its plain roles in
/// canonical order (<c>admin</c>, <c>driver</c>, <c>admin,driver</c>, …; omitted when none).
/// <c>service.version</c> defaults from the entry assembly's informational version (the git SHA).
/// Operator keys under <c>OtOpcUa:Telemetry</c> are applied last (see
/// <see cref="ZbTelemetryConfigurationExtensions.ApplyConfiguration"/>).
/// </summary>
/// <param name="o">The options to populate.</param>
/// <param name="configuration">The host configuration (exporter keys).</param>
/// <param name="roles">The node's resolved roles.</param>
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"));
}
/// <summary>The plain (non-cluster) roles, lower-case, sorted and comma-joined; null when none.</summary>
/// <param name="roles">The node's resolved roles.</param>
/// <returns>For example <c>admin,driver</c>, or <see langword="null"/>.</returns>
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);
}
/// <summary>
+1 -1
View File
@@ -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,
@@ -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;
/// <summary>
/// scadaproj#55 — the OTel Resource carries <c>site.id</c> (the node's application Cluster id),
/// <c>node.role</c> (its plain roles) and <c>service.version</c> (entry-assembly default), so two
/// OtOpcUa nodes are distinguishable by more than <c>service.instance.id</c>.
/// </summary>
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<TracerProvider>().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();
}
}