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