Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs
T
Joseph Doherty 5ef27fd9a3 feat(telemetry): deploy-failure, S&F activity, script-run and DCL-transition instruments (ScadaBridge#81)
Adds four instruments on the already-allow-listed ZB.MOM.WW.ScadaBridge meter:
scadabridge.deployments.failed{reason}, scadabridge.store_and_forward.activity{action,category},
scadabridge.site.script.runs{outcome} + scadabridge.site.script.duration{outcome} (ms),
scadabridge.site.data_connections.transitions{state}. Queue depth, DCL connection state and
deploy-applied already existed. No new Meter, so ZbTelemetryOptions.Meters needs no change.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:17:53 -05:00

559 lines
32 KiB
C#

using System.Diagnostics.Metrics;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Observability;
/// <summary>
/// Central <see cref="Meter"/> + instrument definitions for ScadaBridge's application
/// telemetry, modelled on OtOpcUa's <c>OtOpcUaTelemetry</c>. Modules emit through these
/// pre-created instruments so a single OpenTelemetry / Prometheus binding in
/// <c>Host</c> (registered via <c>AddZbTelemetry</c> with this meter named in
/// <c>ZbTelemetryOptions.Meters</c>) 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
/// <c>scadabridge.&lt;subsystem&gt;.&lt;event&gt;</c>. 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.
/// </summary>
public static class ScadaBridgeTelemetry
{
/// <summary>The meter name registered with OTel via <c>ZbTelemetryOptions.Meters</c>.</summary>
public const string MeterName = "ZB.MOM.WW.ScadaBridge";
/// <summary>Singleton <see cref="Meter"/> all instruments hang off.</summary>
private static readonly Meter Meter = new(MeterName);
// ---------------- Counters ----------------
/// <summary>Incremented each time a deployment is successfully applied.</summary>
private static readonly Counter<long> _deploymentsApplied =
Meter.CreateCounter<long>("scadabridge.deployments.applied", unit: "1",
description: "Deployments applied.");
/// <summary>
/// Incremented for each deployment that ends in a terminal failure (ScadaBridge#81), tagged
/// <c>reason</c>: <c>timeout</c> (the site round-trip timed out), <c>error</c> (any other fault
/// in the send or post-send persistence path) or <c>site_failed</c> (the site answered with a
/// non-Success status). Pairs with <c>scadabridge.deployments.applied</c> to give the deploy
/// success ratio; the rate-limited log lines cannot be alerted on.
/// </summary>
private static readonly Counter<long> _deploymentsFailed =
Meter.CreateCounter<long>("scadabridge.deployments.failed", unit: "1",
description: "Deployments that ended in a terminal failure, tagged by reason (timeout|error|site_failed).");
/// <summary>
/// Incremented for each store-and-forward lifecycle event (ScadaBridge#81), tagged <c>action</c>
/// (<c>Queued</c>, <c>Delivered</c>, <c>Retried</c>, <c>Parked</c>, <c>Retry</c>, <c>Discard</c>)
/// and <c>category</c>. A sustained <c>Parked</c> rate is the alertable signal that messages are
/// exhausting retries; the queue-depth gauge alone cannot distinguish a drain from a park.
/// </summary>
private static readonly Counter<long> _sfActivity =
Meter.CreateCounter<long>("scadabridge.store_and_forward.activity", unit: "1",
description: "Store-and-forward lifecycle events, tagged by action and category.");
/// <summary>
/// Incremented for each finished site script run (ScadaBridge#81), tagged <c>outcome</c>
/// (<c>completed</c> | <c>failed</c> | <c>timed_out</c>). Deliberately NOT tagged by instance or
/// script name: that cardinality is unbounded across a site's instances.
/// </summary>
private static readonly Counter<long> _scriptRuns =
Meter.CreateCounter<long>("scadabridge.site.script.runs", unit: "1",
description: "Finished site script runs, tagged by outcome (completed|failed|timed_out).");
/// <summary>
/// Incremented each time a data connection actually CHANGES state (ScadaBridge#81), tagged
/// <c>state</c> (the new <see cref="ConnectionHealth"/>). The data_connections gauges show the
/// current picture; this shows flapping that a scrape interval would otherwise average away.
/// </summary>
private static readonly Counter<long> _dataConnectionTransitions =
Meter.CreateCounter<long>("scadabridge.site.data_connections.transitions", unit: "1",
description: "Data connection state changes, tagged by the new state.");
/// <summary>Incremented for each inbound API request, tagged with the API method.</summary>
private static readonly Counter<long> _inboundApiRequests =
Meter.CreateCounter<long>("scadabridge.inbound_api.requests", unit: "1",
description: "Inbound API requests, tagged by method.");
/// <summary>Incremented each time an inbound execution is abandoned (handler outlived its request), tagged with the API method.</summary>
private static readonly Counter<long> _inboundAbandonedExecutions =
Meter.CreateCounter<long>("scadabridge.inbound_api.abandoned_executions", unit: "1",
description: "Inbound API executions abandoned after timeout/abort (handler still running), tagged by method.");
/// <summary>Incremented each time an S&amp;F buffer replication op fails to dispatch/deliver to the peer.</summary>
private static readonly Counter<long> _replicationFailures =
Meter.CreateCounter<long>("scadabridge.store_and_forward.replication.failures", unit: "1",
description: "S&F buffer replication operations that failed to dispatch/deliver to the peer node");
private static readonly Counter<long> _sfResyncCompleted =
Meter.CreateCounter<long>("scadabridge.store_and_forward.resync.completed", unit: "1",
description: "S&F anti-entropy resyncs the standby acknowledged as applied");
private static readonly Counter<long> _sfResyncAckMissing =
Meter.CreateCounter<long>("scadabridge.store_and_forward.resync.ack_missing", unit: "1",
description: "S&F resyncs answered by the active node but never acknowledged by the standby within the ack window (lost chunks / dead peer)");
/// <summary>
/// Incremented each time a per-site live-alarm aggregator re-establishes its site-wide
/// gRPC stream — a NodeA↔NodeB failover flip or a reconcile-driven reopen after the
/// stream was given up. A sustained climb signals a flapping site link.
/// </summary>
private static readonly Counter<long> _liveAlarmStreamReconnects =
Meter.CreateCounter<long>("scadabridge.site.alarm_cache.reconnects", unit: "1",
description: "Live-alarm aggregator site-wide gRPC stream reconnects (NodeA↔NodeB flip or reconcile-driven reopen).");
/// <summary>
/// Incremented for each live delta evicted from a per-site live-alarm aggregator's
/// bounded pre-seed buffer (drop-oldest). Non-zero means a seed/reconcile
/// fan-out ran long enough for the delta storm behind it to exceed the cap — the
/// dropped transitions are recovered by the fan-out's authoritative snapshot, but a
/// sustained climb points at a slow site.
/// </summary>
private static readonly Counter<long> _liveAlarmBufferDrops =
Meter.CreateCounter<long>("scadabridge.site.alarm_cache.buffer_dropped", unit: "1",
description: "Live deltas evicted from a live-alarm aggregator's bounded pre-seed buffer (drop-oldest).");
/// <summary>
/// Incremented by the number of entries the site script compile cache evicts when it crosses its
/// bound (ScadaBridge#68). Eviction does not unload the Roslyn assembly, so each recompile of an
/// evicted body loads another permanent copy: a sustained climb means compile-cache thrash and
/// monotonic working-set growth - raise <c>ScadaBridge:SiteRuntime:ScriptCompileCacheMaxEntries</c>.
/// Alert on this instead of the rate-limited Warning log.
/// </summary>
private static readonly Counter<long> _scriptCompileCacheEvictions =
Meter.CreateCounter<long>("scadabridge.site.script_compile_cache.evictions", unit: "1",
description: "Entries evicted from the site script compile cache at its bound (each recompile of an evicted body loads another permanent Roslyn assembly).");
/// <summary>
/// Incremented for each debug event evicted from a central debug session's bounded
/// pre-snapshot buffer (drop-oldest). The Debug View is lossy-under-backpressure
/// by design; this makes the loss measurable instead of unbounded memory growth.
/// </summary>
private static readonly Counter<long> _debugPreSnapshotDrops =
Meter.CreateCounter<long>("scadabridge.central.debug_view.presnapshot_dropped", unit: "1",
description: "Debug events evicted from a central debug session's bounded pre-snapshot buffer (drop-oldest).");
/// <summary>
/// Incremented for each event evicted from a site-hosted gRPC stream's bounded send
/// channel, tagged by stream kind (<c>instance</c> = Debug View, <c>site-alarms</c> =
/// the site-wide alarm feed behind the operator Alarm Summary).
/// </summary>
private static readonly Counter<long> _siteStreamEventDrops =
Meter.CreateCounter<long>("scadabridge.site.stream.events_dropped", unit: "1",
description: "Events evicted from a site gRPC stream's bounded send channel, tagged by stream kind.");
/// <summary>
/// Register row 66: incremented each time <c>BundleSessionStore.Open</c> refuses a
/// bundle-import session because <c>TransportOptions.MaxConcurrentImportSessions</c>
/// (default 8) is genuinely exhausted — i.e. the store was still at the cap AFTER
/// expired sessions were reclaimed. Reclaim-then-succeed and re-opening an already
/// open session id are deliberately NOT counted: neither turns an operator away.
///
/// Row 53 shipped the companion open-sessions gauge and a Warning log, but a log line
/// is not alertable. The pair is what distinguishes the two failure modes: rejections
/// climbing while the gauge oscillates below the cap is genuine concurrency (raise the
/// cap), whereas rejections climbing while the gauge sits pinned at the cap is a
/// session leak like register row 43's (find the leak — raising the cap only defers it).
/// </summary>
private static readonly Counter<long> _transportImportSessionsRejected =
Meter.CreateCounter<long>("scadabridge.central.transport.import_sessions.rejected", unit: "1",
description: "Bundle-import sessions rejected because the concurrent-import-session cap was exhausted.");
/// <summary>
/// Register row 34: incremented once per site-event-log purge tick that stopped at
/// <c>SiteEventLogOptions.MaxPurgedRowsPerTick</c> with expired rows still to delete.
///
/// A single hit is routine (a backlog is being worked through on accelerated catch-up
/// ticks) — what the series is for is the SHAPE. A short burst of hits that stops is a
/// backlog converging; hits that never stop mean the event log is being written faster
/// than one paced tick can purge, i.e. the ceiling or the catch-up interval is now
/// mis-sized for this site's event rate. The paired log line is rate-limited and so
/// cannot answer that question on its own.
/// </summary>
private static readonly Counter<long> _siteEventLogPurgeCeilingHits =
Meter.CreateCounter<long>("scadabridge.site.event_log.purge_ceiling_hits", unit: "1",
description: "Site event log purge ticks that stopped at the per-tick delete ceiling with work remaining.");
// ---------------- Histograms ----------------
/// <summary>
/// Distribution of how many events each site→central stream frame carried (R2 — gRPC
/// event batching), tagged by stream kind. Recorded ONLY for subscriptions that
/// negotiated batching, so the series' very existence says "this central speaks the
/// batched wire". A distribution pinned at 1 means the coalescing window never sees a
/// backlog (the site is quiet, or the window is too small to be earning anything);
/// mass at the size cap means the cap, not the window, is the binding constraint.
/// </summary>
private static readonly Histogram<int> _siteStreamBatchSize =
Meter.CreateHistogram<int>("scadabridge.site.stream.batch_size", unit: "1",
description: "Events per site gRPC stream frame (1 = unbatched frame), tagged by stream kind.");
/// <summary>Duration of finished site script runs in milliseconds (ScadaBridge#81), tagged by outcome.</summary>
private static readonly Histogram<long> _scriptRunDuration =
Meter.CreateHistogram<long>("scadabridge.site.script.duration", unit: "ms",
description: "Site script run duration in milliseconds, tagged by outcome (completed|failed|timed_out).");
// ---------------- Observable gauges ----------------
/// <summary>
/// Current count of open central→site gRPC subscriber streams, mutated via
/// <see cref="Interlocked"/>. See <see cref="_siteStreamSubscribersActive"/>'s gauge for
/// why this is NOT a data-connection count.
/// </summary>
private static long _siteStreamSubscriberCount;
/// <summary>
/// Register row 54: last-known state of every data connection on this site node, keyed by
/// connection name. Written by the DCL at its four state-transition points and read by the
/// data-connection gauges below.
/// </summary>
/// <remarks>
/// A static registry, matching <see cref="_siteStreamSubscriberCount"/>'s precedent, rather
/// than a DI-injected holder: the DCL actors already receive <c>ISiteHealthCollector</c>
/// through their constructors, and threading a second dependency through both actor
/// constructors would ripple into every test that builds them. A site node hosts exactly
/// one DCL, so a process-global registry and a per-node one are the same thing here.
/// </remarks>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ConnectionHealth>
_dataConnectionStates = new(StringComparer.Ordinal);
/// <summary>Current count of running per-site live-alarm aggregators, mutated via <see cref="Interlocked"/>.</summary>
private static long _liveAlarmAggregatorsActive;
/// <summary>Provider that yields the live StoreAndForward queue depth; set by a later task.</summary>
private static Func<long>? _queueDepthProvider;
/// <summary>
/// Register row 53: provider that yields the number of currently open (unexpired)
/// Transport bundle-import sessions, registered by the Transport component's
/// session-eviction hosted service. Null on a site node (Transport is central-only),
/// where the gauge simply reports 0.
/// </summary>
private static Func<long>? _importSessionCountProvider;
#pragma warning disable IDE0052 // Held to keep the observable gauges alive for the meter's lifetime.
/// <summary>
/// Gauge reporting the number of open central→site gRPC subscriber streams.
/// </summary>
/// <remarks>
/// Register row 54 RENAMED this from <c>scadabridge.site.connection.up</c>
/// (<c>scadabridge_site_connection_up</c> in Prometheus). The old name read as a
/// data-connection health signal, but it has only ever counted open subscriber streams
/// on <c>SiteStreamGrpcServer</c> — Debug View and the site-wide alarm feed. It therefore
/// sits at 0 on a perfectly healthy site that nobody is watching, and an alert written
/// against the old name fired on an idle operator console rather than a broken device.
/// For real data-connection health use the <c>scadabridge.site.data_connections.*</c>
/// gauges below. Counts both stream kinds together (no <c>stream</c> tag).
/// </remarks>
private static readonly ObservableGauge<long> _siteStreamSubscribersActive =
Meter.CreateObservableGauge<long>("scadabridge.site.stream.subscribers.active",
() => Interlocked.Read(ref _siteStreamSubscriberCount),
unit: "1",
description: "Open central→site gRPC subscriber streams (Debug View + site-wide alarm feed). NOT data connections.");
/// <summary>
/// Register row 54: gauge reporting how many of this site node's data connections are
/// currently in <see cref="ConnectionHealth.Connected"/> — the real data-connection
/// signal the misnamed stream-subscriber gauge was being mistaken for.
/// </summary>
/// <remarks>
/// Read together with <see cref="_dataConnectionsTotal"/>: <c>connected &lt; total</c> is the
/// alertable condition, and neither number alone distinguishes "one device down" from
/// "nothing configured". Expect **0 on the standby node** — connections are opened by the
/// deployment path on the active node only, so a standby reporting 0 is healthy, not a
/// fault. Alert on the active node, or on <c>max()</c> across the pair.
/// </remarks>
private static readonly ObservableGauge<long> _dataConnectionsConnected =
Meter.CreateObservableGauge<long>("scadabridge.site.data_connections.connected",
CountDataConnections,
unit: "1",
description: "Data connections on this site node currently in the Connected state.");
/// <summary>
/// Register row 54: gauge reporting how many data connections this site node knows about,
/// in any state. The denominator for <see cref="_dataConnectionsConnected"/>.
/// </summary>
private static readonly ObservableGauge<long> _dataConnectionsTotal =
Meter.CreateObservableGauge<long>("scadabridge.site.data_connections.total",
() => (long)_dataConnectionStates.Count,
unit: "1",
description: "Data connections known to this site node, in any state.");
/// <summary>Gauge reporting the current StoreAndForward queue depth via the registered provider.</summary>
private static readonly ObservableGauge<long> _storeAndForwardQueueDepth =
Meter.CreateObservableGauge<long>("scadabridge.store_and_forward.queue.depth",
() => Volatile.Read(ref _queueDepthProvider) is { } p ? p() : 0L,
unit: "items",
description: "Current StoreAndForward queue depth.");
/// <summary>Gauge reporting the number of currently running per-site live-alarm aggregators.</summary>
private static readonly ObservableGauge<long> _liveAlarmAggregatorsActiveGauge =
Meter.CreateObservableGauge<long>("scadabridge.site.alarm_cache.aggregators.active",
() => Interlocked.Read(ref _liveAlarmAggregatorsActive),
unit: "1",
description: "Number of per-site live-alarm aggregators currently running on the active central node.");
/// <summary>
/// Register row 53: gauge reporting the number of open Transport bundle-import
/// sessions on this central node. Each open session pins a fully decrypted bundle
/// in memory and consumes one of <c>TransportOptions.MaxConcurrentImportSessions</c>
/// (default 8), so a series that plateaus at the cap is the signature of leaked
/// sessions — the failure mode register row 43 could only be inferred from rotating
/// test failures because nothing counted them.
/// </summary>
private static readonly ObservableGauge<long> _transportImportSessionsOpen =
Meter.CreateObservableGauge<long>("scadabridge.central.transport.import_sessions.open",
() => Volatile.Read(ref _importSessionCountProvider) is { } p ? p() : 0L,
unit: "1",
description: "Number of currently open bundle-import sessions on this central node.");
#pragma warning restore IDE0052
// ---------------- Emit helpers ----------------
/// <summary>Records that a deployment was applied.</summary>
public static void RecordDeploymentApplied() => _deploymentsApplied.Add(1);
/// <summary>Records one deployment that ended in a terminal failure (ScadaBridge#81).</summary>
/// <param name="reason">One of <c>timeout</c>, <c>error</c>, <c>site_failed</c>.</param>
public static void RecordDeploymentFailed(string reason) =>
_deploymentsFailed.Add(1, new KeyValuePair<string, object?>("reason", reason));
/// <summary>Records one store-and-forward lifecycle event (ScadaBridge#81).</summary>
/// <param name="action">The activity action, e.g. <c>Parked</c>.</param>
/// <param name="category">The message category name.</param>
public static void RecordStoreAndForwardActivity(string action, string category) =>
_sfActivity.Add(1,
new KeyValuePair<string, object?>("action", action),
new KeyValuePair<string, object?>("category", category));
/// <summary>Records one finished site script run and its duration (ScadaBridge#81).</summary>
/// <param name="outcome">One of <c>completed</c>, <c>failed</c>, <c>timed_out</c>.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public static void RecordScriptRun(string outcome, long durationMs)
{
var tag = new KeyValuePair<string, object?>("outcome", outcome);
_scriptRuns.Add(1, tag);
_scriptRunDuration.Record(durationMs < 0 ? 0 : durationMs, tag);
}
/// <summary>Records an inbound API request for the given <paramref name="method"/>.</summary>
/// <param name="method">The API method the request targeted.</param>
public static void RecordInboundApiRequest(string method) =>
_inboundApiRequests.Add(1, new KeyValuePair<string, object?>("method", method));
/// <summary>Records that an inbound execution was abandoned (handler outlived its request) for the given <paramref name="method"/>.</summary>
/// <param name="method">The API method whose execution was abandoned.</param>
public static void RecordInboundAbandonedExecution(string method) =>
_inboundAbandonedExecutions.Add(1, new KeyValuePair<string, object?>("method", method));
/// <summary>Records one failed S&amp;F replication dispatch.</summary>
public static void RecordReplicationFailure() => _replicationFailures.Add(1);
/// <summary>Records one acknowledged (applied) S&amp;F buffer resync.</summary>
public static void RecordSfResyncCompleted() => _sfResyncCompleted.Add(1);
/// <summary>Records one resync whose ack window expired.</summary>
public static void RecordSfResyncAckMissing() => _sfResyncAckMissing.Add(1);
/// <summary>
/// Records one bundle-import session rejected on a genuine concurrent-session cap hit
/// (register row 66). Call ONLY from the post-eviction recheck in
/// <c>BundleSessionStore.Open</c> — counting the pre-eviction check would inflate the
/// series with reclaim-then-succeed cases that never turned an operator away.
/// </summary>
public static void RecordImportSessionRejected() => _transportImportSessionsRejected.Add(1);
/// <summary>
/// Records one site-event-log purge tick that stopped at its per-tick delete ceiling
/// (register row 34). Call ONLY when rows genuinely remain to be purged — a tick that
/// finished the work and happened to land exactly on the ceiling has nothing to report.
/// </summary>
public static void RecordSiteEventLogPurgeCeilingHit() => _siteEventLogPurgeCeilingHits.Add(1);
/// <summary>
/// Records that a central→site gRPC subscriber stream opened (increments
/// <c>scadabridge.site.stream.subscribers.active</c>). Renamed from
/// <c>SiteConnectionOpened</c> by register row 54 — it never counted data connections.
/// </summary>
public static void SiteStreamSubscriberOpened() => Interlocked.Increment(ref _siteStreamSubscriberCount);
/// <summary>
/// Records that a central→site gRPC subscriber stream closed (decrements
/// <c>scadabridge.site.stream.subscribers.active</c>). Renamed from
/// <c>SiteConnectionClosed</c> by register row 54.
/// </summary>
public static void SiteStreamSubscriberClosed() => Interlocked.Decrement(ref _siteStreamSubscriberCount);
/// <summary>
/// Records the current state of one data connection, feeding the
/// <c>scadabridge.site.data_connections.*</c> gauges (register row 54). Called by the DCL
/// at each connection state transition; safe to call repeatedly with the same value.
/// </summary>
/// <param name="connectionName">The data connection's name (unique per site node).</param>
/// <param name="state">The connection's new state.</param>
public static void RecordDataConnectionState(string connectionName, ConnectionHealth state)
{
if (string.IsNullOrEmpty(connectionName))
{
return;
}
var previous = _dataConnectionStates.TryGetValue(connectionName, out var prior) ? prior : (ConnectionHealth?)null;
_dataConnectionStates[connectionName] = state;
if (previous != state)
{
_dataConnectionTransitions.Add(1, new KeyValuePair<string, object?>("state", state.ToString()));
}
}
/// <summary>
/// Drops a data connection from the gauges (register row 54). Called when the DCL removes
/// a connection, so an undeployed connection stops counting toward the total rather than
/// lingering as a permanently-not-connected row that would alert forever.
/// </summary>
/// <param name="connectionName">The data connection's name.</param>
public static void RemoveDataConnection(string connectionName)
{
if (string.IsNullOrEmpty(connectionName))
{
return;
}
_dataConnectionStates.TryRemove(connectionName, out _);
}
/// <summary>Counts data connections currently in <see cref="ConnectionHealth.Connected"/>.</summary>
/// <returns>The connected count.</returns>
private static long CountDataConnections()
{
long connected = 0;
foreach (var state in _dataConnectionStates.Values)
{
if (state == ConnectionHealth.Connected)
{
connected++;
}
}
return connected;
}
/// <summary>Records that a per-site live-alarm aggregator started (increments the active-aggregator gauge).</summary>
public static void LiveAlarmAggregatorStarted() => Interlocked.Increment(ref _liveAlarmAggregatorsActive);
/// <summary>Records that a per-site live-alarm aggregator stopped (decrements the active-aggregator gauge).</summary>
public static void LiveAlarmAggregatorStopped() => Interlocked.Decrement(ref _liveAlarmAggregatorsActive);
/// <summary>Records that a per-site live-alarm aggregator re-established its site-wide gRPC stream.</summary>
public static void RecordLiveAlarmStreamReconnect() => _liveAlarmStreamReconnects.Add(1);
/// <summary>Records live deltas evicted from a live-alarm aggregator's bounded pre-seed buffer.</summary>
/// <param name="count">Number of deltas evicted.</param>
public static void RecordLiveAlarmBufferDrop(long count = 1) => _liveAlarmBufferDrops.Add(count);
/// <summary>Records entries evicted from the site script compile cache.</summary>
/// <param name="count">Number of entries evicted by one sweep.</param>
public static void RecordScriptCompileCacheEvictions(long count) => _scriptCompileCacheEvictions.Add(count);
/// <summary>Records debug events evicted from a debug session's bounded pre-snapshot buffer.</summary>
/// <param name="count">Number of events evicted.</param>
public static void RecordDebugPreSnapshotDrop(long count = 1) => _debugPreSnapshotDrops.Add(count);
/// <summary>Records an event evicted from a site gRPC stream's bounded send channel.</summary>
/// <param name="streamKind">Stream kind tag (<c>instance</c> or <c>site-alarms</c>).</param>
public static void RecordSiteStreamEventDropped(string streamKind) =>
_siteStreamEventDrops.Add(1, new KeyValuePair<string, object?>("stream", streamKind));
/// <summary>
/// Records how many events one site gRPC stream frame carried. Called once per emitted
/// frame on a batching-negotiated subscription (never on an un-negotiated one, where it
/// would degenerate into a per-event instrument on the hottest path in the product).
/// </summary>
/// <param name="streamKind">Stream kind tag (<c>instance</c> or <c>site-alarms</c>).</param>
/// <param name="events">Events packed into the frame; 1 for a plain unbatched frame.</param>
public static void RecordSiteStreamBatchSize(string streamKind, int events) =>
_siteStreamBatchSize.Record(events, new KeyValuePair<string, object?>("stream", streamKind));
/// <summary>
/// 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.
/// </summary>
/// <param name="provider">A callback returning the current queue depth.</param>
public static void SetQueueDepthProvider(Func<long> provider)
{
if (provider is null)
{
return;
}
Volatile.Write(ref _queueDepthProvider, provider);
}
/// <summary>
/// Clears the StoreAndForward queue-depth provider, but only if the currently
/// registered provider is the exact <paramref name="provider"/> delegate passed in
/// (reference-equal compare-and-clear). This lets a StoreAndForward service deregister
/// its own provider on graceful stop without stomping a newer instance that already
/// re-registered into the process-global slot: if a late stop of the old instance
/// passes its (now-superseded) delegate, the identity check fails and the newer
/// provider is preserved. After a successful clear the gauge falls back to reporting 0.
/// Mirrors <see cref="SetQueueDepthProvider"/>'s signature and <see cref="Volatile"/>
/// access pattern.
/// </summary>
/// <param name="provider">The provider delegate to remove; ignored unless it is the
/// one currently registered.</param>
public static void ClearQueueDepthProvider(Func<long> provider)
{
if (provider is null)
{
return;
}
// Compare-and-clear: only null the slot when it still holds the caller's
// delegate, so a stale stop cannot clobber a successor's provider.
Interlocked.CompareExchange(ref _queueDepthProvider, null, provider);
}
/// <summary>
/// Register row 53: registers the provider the open-import-sessions gauge reads on
/// each observation. Called by the Transport component's session-eviction hosted
/// service on start. A null provider is ignored so the gauge falls back to 0.
/// The callback is invoked synchronously on the metrics collection thread, so it
/// must stay cheap — the Transport implementation reads a
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/> it
/// already owns.
/// </summary>
/// <param name="provider">A callback returning the current open-session count.</param>
public static void SetImportSessionCountProvider(Func<long> provider)
{
if (provider is null)
{
return;
}
Volatile.Write(ref _importSessionCountProvider, provider);
}
/// <summary>
/// Clears the open-import-sessions provider, but only if the currently registered
/// provider is the exact <paramref name="provider"/> delegate passed in
/// (reference-equal compare-and-clear), so a late stop of a superseded instance
/// cannot stomp a newer registrant. Mirrors <see cref="ClearQueueDepthProvider"/>.
/// </summary>
/// <param name="provider">The provider delegate to remove; ignored unless it is the
/// one currently registered.</param>
public static void ClearImportSessionCountProvider(Func<long> provider)
{
if (provider is null)
{
return;
}
Interlocked.CompareExchange(ref _importSessionCountProvider, null, provider);
}
}