using System.Diagnostics; using System.Diagnostics.Metrics; namespace ZB.MOM.WW.OtOpcUa.Commons.Observability; /// /// Central + definitions for OtOpcUa. /// All Akka actors, the OPC UA publish path, and the deploy coordinator emit through these /// pre-created instruments so a single OpenTelemetry / Prometheus binding in Host /// 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 /// otopcua.<subsystem>.<event>. Subsystem is one of: deploy, driver, /// virtualtag, scriptedalarm, opcua, redundancy. /// public static class OtOpcUaTelemetry { public const string MeterName = "ZB.MOM.WW.OtOpcUa"; public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa"; /// Singleton all counters/histograms hang off. public static readonly Meter Meter = new(MeterName); /// Singleton used to start spans wrapping deploy/apply/rebuild. public static readonly ActivitySource ActivitySource = new(ActivitySourceName); // ---------------- Deployment / driver-host coordination ---------------- /// Incremented every time DriverHostActor finishes applying a deployment (Ack or Reject). public static readonly Counter DeploymentApplied = Meter.CreateCounter("otopcua.deploy.applied", unit: "{deployment}", description: "Deployments applied by a driver-role node (outcome=ack|reject)."); /// Time from DriverHostActor receiving DispatchDeployment to emitting the ack/reject. public static readonly Histogram DeploymentApplyDurationSec = Meter.CreateHistogram("otopcua.deploy.apply.duration", unit: "s", description: "Driver-role apply latency from DispatchDeployment → Ack/Reject."); /// DriverInstanceActor spawn count (added=new instance; stop=disposed). public static readonly Counter DriverInstanceLifecycle = Meter.CreateCounter("otopcua.driver.lifecycle", unit: "{event}", description: "DriverInstanceActor lifecycle transitions (event=spawn|stop|fault)."); /// /// Poll cycles that ran longer than their subscription's publishing interval, as counted by /// PollGroupEngine.OverrunCount. Core.Abstractions deliberately carries no metrics /// dependency, so each poll driver wires this counter to the engine's onOverrun callback; /// this is the production consumer that surface was always missing. Tagged /// (driver_type, a DriverTypeNames constant) /// + (driver_instance) — snake_case, per the /// convention those two consts document. /// A non-zero and climbing rate means the device cannot sustain its configured poll rate — the /// loop is already running flat out. /// public static readonly Counter PollOverruns = Meter.CreateCounter("otopcua.driver.poll.overrun", unit: "{overrun}", description: "Poll cycles that ran longer than the poll interval (slow success and slow failure both count — see PollGroupEngine.NoteOverrun)."); /// /// Tag key carrying the DriverTypeNames constant on . /// The snake_case spelling is load-bearing, not a style choice. It is the same /// literal key emits (DriverInstanceActor), and /// every other instrument on this uses bare/snake_case keys /// (event, kind, outcome, site, reason). A dotted /// driver.type here would silently split a dashboard's "group by driver type" across /// two key names that no single query can join. The seven poll drivers reference this const /// rather than a literal so that can never drift back. /// public const string PollOverrunDriverTypeTag = "driver_type"; /// /// Tag key carrying the emitting driver's instance id on . Cardinality /// is bounded by the number of configured driver instances on the node. Snake_case for the same /// convention reason as . /// public const string PollOverrunDriverInstanceTag = "driver_instance"; // ---------------- VirtualTag / ScriptedAlarm engines ---------------- public static readonly Counter VirtualTagEval = Meter.CreateCounter("otopcua.virtualtag.eval", unit: "{eval}", description: "Virtual-tag evaluations attempted (outcome=ok|fail|skip)."); public static readonly Counter ScriptedAlarmTransition = Meter.CreateCounter("otopcua.scriptedalarm.transition", unit: "{transition}", description: "Scripted-alarm state transitions (state=active|acknowledged|inactive)."); // ---------------- OPC UA address-space + redundancy ---------------- public static readonly Counter OpcUaSinkWrite = Meter.CreateCounter("otopcua.opcua.sink.write", unit: "{write}", description: "Writes that landed in IOpcUaAddressSpaceSink (kind=value|alarm|rebuild)."); /// Address-space apply/materialise passes that swallowed at least one sink failure — /// a failed rebuild (kind=rebuild) or per-node materialise failures (kind=nodes). A non-zero rate /// means the running server holds a stale or partially-materialised address space despite a /// reported-successful deploy (archreview 01/S-1). public static readonly Counter OpcUaApplyFailed = Meter.CreateCounter("otopcua.opcua.apply.failed", unit: "{apply}", description: "Apply/materialise passes with swallowed sink failures (kind=rebuild|nodes)."); public static readonly Counter ServiceLevelChange = Meter.CreateCounter("otopcua.redundancy.service_level_change", unit: "{change}", description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state."); /// Archreview 03/S15 — tag refs a driver left BadNoCommunication after it reconnected: /// the host marked them Bad on the outage, and the driver never re-published them. Non-zero means a /// driver that reconnects without a full re-publish (a push driver missing its initial push, or a /// value-only dedup swallowing an unchanged value), which leaves those nodes Bad indefinitely. /// Tagged driver + driver_type. public static readonly Counter DriverCommsLatchedRefs = Meter.CreateCounter("otopcua.driver.comms_latched_refs", unit: "{ref}", description: "Refs still BadNoCommunication after their driver reconnected (driver, driver_type)."); /// Inbound operations denied by the Primary data-plane gate (archreview 03/S4) — /// site=write|ack|alarm-emit, reason=secondary|detached|role-unknown. public static readonly Counter PrimaryGateDenied = Meter.CreateCounter("otopcua.redundancy.primary_gate_denied", unit: "{denial}", description: "Operations denied by the Primary gate (site=write|ack|alarm-emit, reason=secondary|detached|role-unknown)."); /// /// API-key store lookups the 03/S18 bound intervened on (outcome=timeout|cache_hit). /// /// /// One counter with an outcome tag rather than two: timeout is the alarm signal that /// the ConfigDb is not answering the OPC UA impersonation callback, and cache_hit is the /// ratio that says whether the TTL is doing anything. A missing cache_hit stream on a busy /// fused central is how you notice Security:ApiKeys:StoreCacheSeconds was set to 0 by /// accident. A plain read-through meters neither — the counter names interventions, not traffic. /// public static readonly Counter ApiKeyStoreBounded = Meter.CreateCounter("otopcua.opcua.apikey_store.bounded", unit: "{lookup}", description: "API-key store lookups the bound intervened on (outcome=timeout|cache_hit)."); // ---------------- Convenience helpers ---------------- /// /// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns /// null when no listener is attached so the call site stays cheap on undecorated builds. /// /// The deployment identifier to tag the span with. /// The started activity, or null when no listener is attached. public static Activity? StartDeployApplySpan(string deploymentId) { var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal); activity?.SetTag("otopcua.deployment_id", deploymentId); return activity; } /// Span wrapping a full OPC UA address-space rebuild (AddressSpace plan → apply). /// The started activity, or null when no listener is attached. public static Activity? StartAddressSpaceRebuildSpan() => ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal); }