using System.Diagnostics.Metrics; using ZB.MOM.WW.MxGateway.Server.Metrics; namespace ZB.MOM.WW.MxGateway.Tests.Metrics; public sealed class GatewayMetricsTests { /// Verifies that snapshot reflects all metric updates. [Fact] public void GetSnapshot_ReflectsSessionWorkerCommandEventAndFaultUpdates() { using GatewayMetrics metrics = new(); metrics.SessionOpened(); metrics.WorkerStarted(TimeSpan.FromMilliseconds(250)); metrics.CommandStarted("Register"); metrics.CommandSucceeded("Register", TimeSpan.FromMilliseconds(10)); metrics.CommandStarted("WriteSecured"); metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(12)); metrics.EventReceived("session-1", "OnDataChange"); metrics.EventReceived("session-1", "OnDataChange"); // GWC-30: the worker queue-depth gauge sums one live source per worker client, so the two // registrations below stand in for two concurrent sessions holding 3 and 4 events. using IDisposable workerDepthSourceA = metrics.RegisterWorkerEventQueueDepthSource(static () => 3); using IDisposable workerDepthSourceB = metrics.RegisterWorkerEventQueueDepthSource(static () => 4); // GWC-15: the gRPC stream queue-depth gauge sums live backlog sources at collection time // rather than tracking a pushed running total. Register a source reporting 3. using IDisposable backlogSource = metrics.RegisterEventStreamBacklogSource(static () => 3); metrics.QueueOverflow("session-events"); metrics.Fault("CommandTimeout"); metrics.WorkerKilled("CommandTimeout"); metrics.WorkerStopped("Killed"); metrics.HeartbeatFailed("session-1"); metrics.StreamDisconnected("ClientCancelled"); metrics.SessionClosed(); GatewayMetricsSnapshot snapshot = metrics.GetSnapshot(); Assert.Equal(0, snapshot.OpenSessions); Assert.Equal(0, snapshot.WorkersRunning); Assert.Equal(7, snapshot.WorkerEventQueueDepth); Assert.Equal(3, snapshot.GrpcEventStreamQueueDepth); Assert.Equal(1, snapshot.SessionsOpened); Assert.Equal(1, snapshot.SessionsClosed); Assert.Equal(2, snapshot.CommandsStarted); Assert.Equal(1, snapshot.CommandsSucceeded); Assert.Equal(1, snapshot.CommandsFailed); Assert.Equal(2, snapshot.EventsReceived); Assert.Equal(1, snapshot.QueueOverflows); Assert.Equal(1, snapshot.Faults); Assert.Equal(1, snapshot.WorkerKills); Assert.Equal(1, snapshot.WorkerExits); Assert.Equal(1, snapshot.HeartbeatFailures); Assert.Equal(1, snapshot.StreamDisconnects); Assert.Equal(1, snapshot.CommandFailuresByMethod["WriteSecured"]); Assert.Equal(2, snapshot.EventsByFamily["OnDataChange"]); Assert.Equal(2, snapshot.EventsBySession["session-1"]); } /// /// GWC-30: the worker queue-depth gauge sums every registered source rather than holding a /// single pushed scalar, so concurrent sessions add up instead of overwriting one another, /// and a disposed registration (a worker client going away) drops out of the sum. Disposal /// is idempotent because a client's dispose path can run twice. /// [Fact] public void WorkerEventQueueDepthSources_SumAcrossRegistrationsAndDropOnDispose() { using GatewayMetrics metrics = new(); IDisposable firstSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 3); using IDisposable secondSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 4); Assert.Equal(7, metrics.GetSnapshot().WorkerEventQueueDepth); firstSource.Dispose(); Assert.Equal(4, metrics.GetSnapshot().WorkerEventQueueDepth); firstSource.Dispose(); Assert.Equal(4, metrics.GetSnapshot().WorkerEventQueueDepth); } /// /// A depth source reads a lock-free counter that a racing decrement can momentarily push /// below zero, so the sum clamps each reading instead of rejecting it — the pull model has /// no caller to throw back at. /// [Fact] public void WorkerEventQueueDepthSources_ClampNegativeReadingsToZero() { using GatewayMetrics metrics = new(); using IDisposable negativeSource = metrics.RegisterWorkerEventQueueDepthSource(static () => -5); using IDisposable positiveSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 4); Assert.Equal(4, metrics.GetSnapshot().WorkerEventQueueDepth); } /// /// Verifies the exported gauge keeps the name mxgateway.events.worker_queue.depth and /// reports the summed sources, so the pull-model rework is invisible to exporters. /// [Fact] public void WorkerEventQueueDepthGauge_ReportsSummedSources() { using GatewayMetrics metrics = new(); using MeterListener listener = new(); int? capturedDepth = null; listener.InstrumentPublished = (instrument, meterListener) => { if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "mxgateway.events.worker_queue.depth") { meterListener.EnableMeasurementEvents(instrument); } }; listener.SetMeasurementEventCallback( (instrument, measurement, _, _) => { if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "mxgateway.events.worker_queue.depth") { capturedDepth = measurement; } }); listener.Start(); using IDisposable firstSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 5); using IDisposable secondSource = metrics.RegisterWorkerEventQueueDepthSource(static () => 6); listener.RecordObservableInstruments(); Assert.Equal(11, capturedDepth); } /// /// The command counters are incremented with rather than under the /// process-wide metrics lock, so this asserts no increment is lost when every gRPC thread /// records at once. /// [Fact] public void CommandCounters_CountEveryConcurrentInvocation() { const int workers = 8; const int perWorker = 500; using GatewayMetrics metrics = new(); Parallel.For(0, workers, _ => { for (int index = 0; index < perWorker; index++) { metrics.CommandStarted("Register"); metrics.CommandSucceeded("Register", TimeSpan.FromMilliseconds(1)); metrics.CommandFailed("WriteSecured", "AuthorizationFailed", TimeSpan.FromMilliseconds(1)); } }); GatewayMetricsSnapshot snapshot = metrics.GetSnapshot(); Assert.Equal(workers * perWorker, snapshot.CommandsStarted); Assert.Equal(workers * perWorker, snapshot.CommandsSucceeded); Assert.Equal(workers * perWorker, snapshot.CommandsFailed); Assert.Equal(workers * perWorker, snapshot.CommandFailuresByMethod["WriteSecured"]); } /// /// Verifies that increments /// mxgateway.alarms.provider_switches by one with the expected from/to/reason tags. /// The listener filters by the specific instance /// to avoid cross-talk between parallel tests. /// [Fact] public void AlarmProviderSwitched_IncrementsCounterWithExpectedTags() { using GatewayMetrics metrics = new(); using MeterListener listener = new(); long capturedValue = 0; string? capturedFrom = null; string? capturedTo = null; string? capturedReason = null; listener.InstrumentPublished = (instrument, meterListener) => { if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "mxgateway.alarms.provider_switches") { meterListener.EnableMeasurementEvents(instrument); } }; listener.SetMeasurementEventCallback( (instrument, measurement, tags, _) => { if (!ReferenceEquals(instrument.Meter, metrics.Meter) || instrument.Name != "mxgateway.alarms.provider_switches") { return; } capturedValue += measurement; foreach (KeyValuePair tag in tags) { switch (tag.Key) { case "from": capturedFrom = tag.Value as string; break; case "to": capturedTo = tag.Value as string; break; case "reason": capturedReason = tag.Value as string; break; } } }); listener.Start(); metrics.AlarmProviderSwitched(1, 2, AlarmProviderSwitchReason.Failover); Assert.Equal(1, capturedValue); Assert.Equal("1", capturedFrom); Assert.Equal("2", capturedTo); Assert.Equal("failover", capturedReason); Assert.Equal(1, metrics.GetSnapshot().AlarmProviderSwitchCount); } /// /// Verifies that updates the /// mxgateway.alarms.provider_mode observable gauge to the value. /// [Fact] public void AlarmProviderSwitched_UpdatesProviderModeGauge() { using GatewayMetrics metrics = new(); using MeterListener listener = new(); int? capturedMode = null; listener.InstrumentPublished = (instrument, meterListener) => { if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "mxgateway.alarms.provider_mode") { meterListener.EnableMeasurementEvents(instrument); } }; listener.SetMeasurementEventCallback( (instrument, measurement, _, _) => { if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "mxgateway.alarms.provider_mode") { capturedMode = measurement; } }); listener.Start(); metrics.AlarmProviderSwitched(1, 2, AlarmProviderSwitchReason.Failover); listener.RecordObservableInstruments(); Assert.Equal(2, capturedMode); } /// /// Verifies that increments /// mxgateway.heartbeats.failed without emitting a session_id tag: the tag is /// unbounded cardinality (every session mints a new exporter time series), so per-session /// attribution is deliberately kept out of the exported counter. /// [Fact] public void HeartbeatFailed_IncrementsCounterWithoutSessionIdTag() { using GatewayMetrics metrics = new(); using MeterListener listener = new(); long capturedValue = 0; bool sawSessionIdTag = false; listener.InstrumentPublished = (instrument, meterListener) => { if (ReferenceEquals(instrument.Meter, metrics.Meter) && instrument.Name == "mxgateway.heartbeats.failed") { meterListener.EnableMeasurementEvents(instrument); } }; listener.SetMeasurementEventCallback( (instrument, measurement, tags, _) => { if (!ReferenceEquals(instrument.Meter, metrics.Meter) || instrument.Name != "mxgateway.heartbeats.failed") { return; } capturedValue += measurement; foreach (KeyValuePair tag in tags) { if (tag.Key == "session_id") { sawSessionIdTag = true; } } }); listener.Start(); metrics.HeartbeatFailed("session-1"); Assert.Equal(1, capturedValue); Assert.False(sawSessionIdTag); Assert.Equal(1, metrics.GetSnapshot().HeartbeatFailures); } /// Verifies that removing session events only affects that session. [Fact] public void RemoveSessionEvents_RemovesOnlyThatSession() { using GatewayMetrics metrics = new(); metrics.EventReceived("session-1", "OnDataChange"); metrics.EventReceived("session-2", "OnWriteComplete"); metrics.RemoveSessionEvents("session-1"); GatewayMetricsSnapshot snapshot = metrics.GetSnapshot(); Assert.Equal(2, snapshot.EventsReceived); Assert.False(snapshot.EventsBySession.ContainsKey("session-1")); Assert.Equal(1, snapshot.EventsBySession["session-2"]); Assert.Equal(1, snapshot.EventsByFamily["OnDataChange"]); Assert.Equal(1, snapshot.EventsByFamily["OnWriteComplete"]); } }