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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user