Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDiagnostics.cs
Joseph Doherty bb1ab47b68 Auto: opcuaclient-4 — diagnostics counters
Per-driver counters surfaced via DriverHealth.Diagnostics for the
driver-diagnostics RPC. New OpcUaClientDiagnostics tracks
PublishRequestCount, NotificationCount, NotificationsPerSecond (5s-half-life
EWMA), MissingPublishRequestCount, DroppedNotificationCount,
SessionResetCount and LastReconnectUtcTicks via Interlocked on the hot path.

DriverHealth gains an optional IReadOnlyDictionary<string,double>?
Diagnostics parameter (defaulted null for back-compat with the seven other
drivers' constructors). OpcUaClientDriver wires Session.Notification +
Session.PublishError on connect and on reconnect-complete (recording a
session-reset there); GetHealth snapshots the counters on every poll so the
RPC sees fresh values without a tick source.

Tests: 11 new OpcUaClientDiagnosticsTests cover counter increments, EWMA
convergence, snapshot shape, GetHealth integration, and DriverHealth
back-compat. Full OpcUaClient.Tests 115/115 green.

Closes #276

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:53:57 -04:00

135 lines
5.9 KiB
C#

using System.Collections.Generic;
using System.Threading;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
/// </summary>
/// <remarks>
/// The counters are operational metrics, not config — they reset to zero when the
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
/// so a quiet subscription doesn't latch the value at the last burst rate.
/// </remarks>
internal sealed class OpcUaClientDiagnostics
{
// ---- Hot-path counters (Interlocked) ----
private long _publishRequestCount;
private long _notificationCount;
private long _missingPublishRequestCount;
private long _droppedNotificationCount;
private long _sessionResetCount;
// ---- EWMA state for NotificationsPerSecond ----
//
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
// the SDK's notification path, not this lock.
private readonly object _ewmaLock = new();
private double _notificationsPerSecond;
private long _lastNotificationTicks;
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
private long _lastReconnectUtcTicks;
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
public long NotificationCount => Interlocked.Read(ref _notificationCount);
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
public DateTime? LastReconnectUtc
{
get
{
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
}
}
public double NotificationsPerSecond
{
get { lock (_ewmaLock) return _notificationsPerSecond; }
}
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
internal void RecordNotification(DateTime nowUtc)
{
Interlocked.Increment(ref _notificationCount);
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
// bursts win, idle periods bleed back to zero.
var nowTicks = nowUtc.Ticks;
lock (_ewmaLock)
{
if (_lastNotificationTicks == 0)
{
_lastNotificationTicks = nowTicks;
// First sample: seed at 0 — we don't know the prior rate. The next sample
// produces a real instRate.
return;
}
var dtTicks = nowTicks - _lastNotificationTicks;
if (dtTicks <= 0)
{
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
// (count was already incremented above) so we don't divide by zero or feed
// an absurd instRate spike.
return;
}
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
var instRate = 1.0 / dtSeconds;
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
_lastNotificationTicks = nowTicks;
}
}
public void RecordSessionReset(DateTime nowUtc)
{
Interlocked.Increment(ref _sessionResetCount);
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
}
/// <summary>
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
/// emitted as ticks to keep the value type uniform.
/// </summary>
public IReadOnlyDictionary<string, double> Snapshot()
{
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
{
["PublishRequestCount"] = PublishRequestCount,
["NotificationCount"] = NotificationCount,
["NotificationsPerSecond"] = NotificationsPerSecond,
["MissingPublishRequestCount"] = MissingPublishRequestCount,
["DroppedNotificationCount"] = DroppedNotificationCount,
["SessionResetCount"] = SessionResetCount,
};
var last = LastReconnectUtc;
if (last is not null)
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
return dict;
}
}