Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across ScadaBridge

AddZbTelemetry (shared OTel Resource + standard instrumentation + /metrics) wired
into both Central and Site composition roots; kept LoggerConfigurationFactory
(min-level governance) and added the shared TraceContextEnricher for trace<->log
correlation. Behaviour-preserving (no AddZbSerilog; factory retained).
This commit is contained in:
Joseph Doherty
2026-06-01 16:05:49 -04:00
7 changed files with 121 additions and 1 deletions
+2
View File
@@ -75,6 +75,8 @@
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.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.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
</ItemGroup>
+2
View File
@@ -18,6 +18,8 @@
<package pattern="ZB.MOM.WW.MxGateway.*" />
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
<package pattern="ZB.MOM.WW.Telemetry" />
<package pattern="ZB.MOM.WW.Telemetry.*" />
</packageSource>
</packageSourceMapping>
<!--
@@ -1,5 +1,6 @@
using Serilog;
using Serilog.Events;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.ScadaBridge.Host;
@@ -85,7 +86,8 @@ public static class LoggerConfigurationFactory
.MinimumLevel.Is(minimumLevel)
.Enrich.WithProperty("SiteId", siteId)
.Enrich.WithProperty("NodeHostname", nodeHostname)
.Enrich.WithProperty("NodeRole", nodeRole);
.Enrich.WithProperty("NodeRole", nodeRole)
.Enrich.With(new TraceContextEnricher());
}
/// <summary>
+18
View File
@@ -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<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
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<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
@@ -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<INodeIdentityProvider, NodeIdentityProvider>();
// 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"];
});
}
}
@@ -31,6 +31,8 @@
<PackageReference Include="ZB.MOM.WW.Health" />
<PackageReference Include="ZB.MOM.WW.Health.Akka" />
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />
<PackageReference Include="ZB.MOM.WW.Telemetry" />
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,79 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Observability adoption: verifies the shared ZB.MOM.WW.Telemetry Prometheus
/// scrape endpoint (<c>/metrics</c>, mounted by <c>app.MapZbMetrics()</c>) is wired into the
/// Central composition root. <c>AddZbTelemetry</c> (registered in
/// <see cref="SiteServiceRegistration.BindSharedOptions"/>) 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 <see cref="HealthCheckTests"/>.
/// </summary>
public class MetricsEndpointTests : IDisposable
{
private readonly List<IDisposable> _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<Program> CreateCentralFactory()
{
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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);
}
}
}