diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs new file mode 100644 index 00000000..1b382bab --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs @@ -0,0 +1,94 @@ +using System.Diagnostics.Metrics; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Observability; + +/// +/// Central + instrument definitions for ScadaBridge's application +/// telemetry, modelled on OtOpcUa's OtOpcUaTelemetry. Modules emit through these +/// pre-created instruments so a single OpenTelemetry / Prometheus binding in +/// Host (registered via AddZbTelemetry with this meter named in +/// ZbTelemetryOptions.Meters) catches everything. No exporter is required — +/// instruments are no-op until a listener attaches, so tests and dev hosts pay nothing +/// for instrumentation that nobody scrapes. +/// +/// Instrument names follow the OpenTelemetry semantic convention pattern +/// scadabridge.<subsystem>.<event>. This task defines the instruments and +/// their emit helpers; four later tasks wire the actual emit points. Until those land the +/// helpers are dormant but inert — calling them is safe and simply records against a meter +/// that nothing observes. +/// +public static class ScadaBridgeTelemetry +{ + /// The meter name registered with OTel via ZbTelemetryOptions.Meters. + public const string MeterName = "ZB.MOM.WW.ScadaBridge"; + + /// Singleton all instruments hang off. + private static readonly Meter Meter = new(MeterName); + + // ---------------- Counters ---------------- + + /// Incremented each time a deployment is successfully applied. + private static readonly Counter _deploymentsApplied = + Meter.CreateCounter("scadabridge.deployments.applied", unit: "1", + description: "Deployments applied."); + + /// Incremented for each inbound API request, tagged with the API method. + private static readonly Counter _inboundApiRequests = + Meter.CreateCounter("scadabridge.inbound_api.requests", unit: "1", + description: "Inbound API requests, tagged by method."); + + // ---------------- Observable gauges ---------------- + + /// Current count of open site connections, mutated via . + private static long _siteConnectionsUp; + + /// Provider that yields the live StoreAndForward queue depth; set by a later task. + private static Func? _queueDepthProvider; + +#pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime. + /// Gauge reporting the number of currently open site connections. + private static readonly ObservableGauge _siteConnectionUp = + Meter.CreateObservableGauge("scadabridge.site.connection.up", + () => Interlocked.Read(ref _siteConnectionsUp), + description: "Number of currently open site connections."); + + /// Gauge reporting the current StoreAndForward queue depth via the registered provider. + private static readonly ObservableGauge _storeAndForwardQueueDepth = + Meter.CreateObservableGauge("scadabridge.store_and_forward.queue.depth", + () => Volatile.Read(ref _queueDepthProvider) is { } p ? p() : 0L, + unit: "items", + description: "Current StoreAndForward queue depth."); +#pragma warning restore IDE0052 + + // ---------------- Emit helpers ---------------- + + /// Records that a deployment was applied. + public static void RecordDeploymentApplied() => _deploymentsApplied.Add(1); + + /// Records an inbound API request for the given . + /// The API method the request targeted. + public static void RecordInboundApiRequest(string method) => + _inboundApiRequests.Add(1, new KeyValuePair("method", method)); + + /// Records that a site connection opened (increments the up-count gauge). + public static void SiteConnectionOpened() => Interlocked.Increment(ref _siteConnectionsUp); + + /// Records that a site connection closed (decrements the up-count gauge). + public static void SiteConnectionClosed() => Interlocked.Decrement(ref _siteConnectionsUp); + + /// + /// Registers the provider the StoreAndForward queue-depth gauge reads on each observation. + /// A later task supplies a provider that reads the real StoreAndForward depth. A null + /// provider is ignored so the gauge falls back to reporting 0. + /// + /// A callback returning the current queue depth. + public static void SetQueueDepthProvider(Func provider) + { + if (provider is null) + { + return; + } + + Volatile.Write(ref _queueDepthProvider, provider); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs index 92cf8b89..8cf4abd5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs @@ -2,6 +2,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog; using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; @@ -122,12 +123,14 @@ public static class SiteServiceRegistration // 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. + // The application meter is named so OTel observes its instruments; emit points are + // 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"]; + o.Meters = [ScadaBridgeTelemetry.MeterName]; }); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs new file mode 100644 index 00000000..c081386e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs @@ -0,0 +1,32 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Observability; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Infrastructure-free guards for : the meter name is the +/// stable value registered with OTel, and the emit helpers are safe to call (they are no-op +/// until follow-on tasks wire real emit points and a listener attaches). +/// +public class ScadaBridgeTelemetryTests +{ + [Fact] + public void MeterName_IsStableValue() + { + Assert.Equal("ZB.MOM.WW.ScadaBridge", ScadaBridgeTelemetry.MeterName); + } + + [Fact] + public void EmitHelpers_DoNotThrow() + { + var ex = Record.Exception(() => + { + ScadaBridgeTelemetry.RecordDeploymentApplied(); + ScadaBridgeTelemetry.RecordInboundApiRequest("X"); + ScadaBridgeTelemetry.SiteConnectionOpened(); + ScadaBridgeTelemetry.SiteConnectionClosed(); + ScadaBridgeTelemetry.SetQueueDepthProvider(() => 5); + }); + + Assert.Null(ex); + } +}