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