fix(opcuaclient): review fixes - drop driver-side OperationLimits chunking (SDK batches), diagnostics as OTel counter, safe discovery selection, options validation at load (#273 #275 #276 #277 #278)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:55:53 -04:00
parent 935ba8d1d4
commit 1219b7b13e
13 changed files with 406 additions and 652 deletions
@@ -1,102 +1,48 @@
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Thread-safe counters for the OPC UA Client driver (#276), surfaced through
/// <c>DriverHealth.Diagnostics</c>. Writers are the SDK's notification / keep-alive threads and
/// the request paths, so every field is touched only through <see cref="Interlocked"/>.
/// Publishes an OPC UA Client driver instance's session/publish events (#276) on the shared
/// <c>otopcua.driver.opcuaclient.events</c> OTel counter, tagged with <c>driver_instance</c> and
/// <c>event</c>. The counter is thread-safe, so the SDK's notification / keep-alive threads record
/// directly with no locking here.
/// </summary>
internal sealed class OpcUaClientDiagnostics
{
private long _publishResponses;
private long _notifications;
private long _dataChanges;
private long _badStatusValues;
private long _publishErrors;
private long _keepAliveMisses;
private long _reconnectAttempts;
private long _reconnectsSucceeded;
private long _reconnectsExhausted;
private long _lastReconnectUtcTicks;
private long _readCalls;
private long _readFailures;
private long _writeCalls;
private long _writeFailures;
private long _chunkedRequests;
/// <summary>A NotificationMessage arrived (one per Publish response that carried data).</summary>
public void RecordPublishResponse() => Interlocked.Increment(ref _publishResponses);
/// <summary>A Publish failed (missing publish requests, subscription errors).</summary>
public void RecordPublishError() => Interlocked.Increment(ref _publishErrors);
public const string PublishResponse = "publish_response";
/// <summary>Items carried by notification messages.</summary>
public const string Notification = "notification";
/// <summary>One monitored-item data change was delivered.</summary>
/// <param name="bad">Whether the value's status code was Bad.</param>
public void RecordDataChange(bool bad)
{
Interlocked.Increment(ref _dataChanges);
if (bad) Interlocked.Increment(ref _badStatusValues);
}
/// <summary>Count raw notification items carried by a message.</summary>
/// <param name="count">Number of items in the message.</param>
public void RecordNotifications(int count) => Interlocked.Add(ref _notifications, count);
public const string DataChange = "data_change";
/// <summary>A delivered data change carried a Bad status.</summary>
public const string BadStatusValue = "bad_status_value";
/// <summary>A Publish failed (missing publish requests, subscription errors).</summary>
public const string PublishError = "publish_error";
/// <summary>A keep-alive came back Bad.</summary>
public void RecordKeepAliveMiss() => Interlocked.Increment(ref _keepAliveMisses);
public const string KeepAliveMiss = "keepalive_miss";
/// <summary>The reconnect handler was armed.</summary>
public void RecordReconnectAttempt() => Interlocked.Increment(ref _reconnectAttempts);
public const string ReconnectAttempt = "reconnect_attempt";
/// <summary>A reconnect landed a new session.</summary>
public void RecordReconnectSucceeded()
{
Interlocked.Increment(ref _reconnectsSucceeded);
Interlocked.Exchange(ref _lastReconnectUtcTicks, DateTime.UtcNow.Ticks);
}
public const string ReconnectSuccess = "reconnect_success";
/// <summary>A reconnect handler exhausted its retry window.</summary>
public void RecordReconnectExhausted() => Interlocked.Increment(ref _reconnectsExhausted);
public const string ReconnectExhausted = "reconnect_exhausted";
/// <summary>A Read request (all chunks) finished.</summary>
/// <param name="failed">Whether any chunk failed on the wire.</param>
public void RecordRead(bool failed)
private readonly string _driverInstanceId;
/// <summary>Creates the recorder for one driver instance.</summary>
/// <param name="driverInstanceId">Value of the <c>driver_instance</c> tag.</param>
public OpcUaClientDiagnostics(string driverInstanceId) => _driverInstanceId = driverInstanceId;
/// <summary>Add <paramref name="count"/> to the counter for <paramref name="evt"/>.</summary>
/// <param name="evt">One of the event constants.</param>
/// <param name="count">Amount to add (default 1).</param>
public void Record(string evt, long count = 1)
{
Interlocked.Increment(ref _readCalls);
if (failed) Interlocked.Increment(ref _readFailures);
if (count <= 0) return;
OtOpcUaTelemetry.OpcUaClientEvents.Add(count,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, _driverInstanceId),
new KeyValuePair<string, object?>("event", evt));
}
/// <summary>A Write request (all chunks) finished.</summary>
/// <param name="failed">Whether any chunk failed on the wire.</param>
public void RecordWrite(bool failed)
{
Interlocked.Increment(ref _writeCalls);
if (failed) Interlocked.Increment(ref _writeFailures);
}
/// <summary>A logical request was split into more than one wire call to honour a server OperationLimit.</summary>
public void RecordChunkedRequest() => Interlocked.Increment(ref _chunkedRequests);
/// <summary>
/// Point-in-time copy for <c>DriverHealth.Diagnostics</c>. Keys are stable and
/// <c>PascalCase</c>; <c>LastReconnectUtcTicks</c> is 0 until a reconnect succeeds.
/// </summary>
/// <returns>A fresh dictionary the caller may keep.</returns>
public IReadOnlyDictionary<string, double> Snapshot() => new Dictionary<string, double>
{
["PublishResponseCount"] = Interlocked.Read(ref _publishResponses),
["NotificationCount"] = Interlocked.Read(ref _notifications),
["DataChangeCount"] = Interlocked.Read(ref _dataChanges),
["BadStatusValueCount"] = Interlocked.Read(ref _badStatusValues),
["PublishErrorCount"] = Interlocked.Read(ref _publishErrors),
["KeepAliveMissCount"] = Interlocked.Read(ref _keepAliveMisses),
["ReconnectAttemptCount"] = Interlocked.Read(ref _reconnectAttempts),
["ReconnectSuccessCount"] = Interlocked.Read(ref _reconnectsSucceeded),
["ReconnectExhaustedCount"] = Interlocked.Read(ref _reconnectsExhausted),
["LastReconnectUtcTicks"] = Interlocked.Read(ref _lastReconnectUtcTicks),
["ReadCallCount"] = Interlocked.Read(ref _readCalls),
["ReadFailureCount"] = Interlocked.Read(ref _readFailures),
["WriteCallCount"] = Interlocked.Read(ref _writeCalls),
["WriteFailureCount"] = Interlocked.Read(ref _writeFailures),
["ChunkedRequestCount"] = Interlocked.Read(ref _chunkedRequests),
};
}