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