From 7dfec6dc8cfe47254eabe00cf096afda62960a2e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:10:58 -0400 Subject: [PATCH] Issue #4: add structured logging and metrics foundation --- docs/gateway-dashboard-design.md | 7 +- docs/gateway-process-design.md | 20 ++ gateway.md | 7 + .../Diagnostics/GatewayLogRedactor.cs | 78 +++++ .../Diagnostics/GatewayLogScope.cs | 33 ++ .../Diagnostics/GatewayLoggerExtensions.cs | 16 + ...tewayRequestLoggingMiddlewareExtensions.cs | 57 ++++ src/MxGateway.Server/GatewayApplication.cs | 4 + .../Metrics/GatewayMetrics.cs | 306 ++++++++++++++++++ .../Metrics/GatewayMetricsSnapshot.cs | 20 ++ .../Diagnostics/GatewayLogRedactorTests.cs | 73 +++++ .../Gateway/GatewayApplicationTests.cs | 12 + .../Metrics/GatewayMetricsTests.cs | 60 ++++ 13 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs create mode 100644 src/MxGateway.Server/Diagnostics/GatewayLogScope.cs create mode 100644 src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs create mode 100644 src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs create mode 100644 src/MxGateway.Server/Metrics/GatewayMetrics.cs create mode 100644 src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs create mode 100644 src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs create mode 100644 src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs diff --git a/docs/gateway-dashboard-design.md b/docs/gateway-dashboard-design.md index d88d587..70e68cc 100644 --- a/docs/gateway-dashboard-design.md +++ b/docs/gateway-dashboard-design.md @@ -105,6 +105,12 @@ Do not let Razor components directly mutate gateway session or worker objects. Create a small read-only dashboard service that projects gateway state into plain DTOs. +`GatewayMetrics.GetSnapshot()` is the metrics input for the first dashboard +projection. It carries current session and worker gauges, command and event +counters, queue depth, and fault totals. The dashboard reads that snapshot +instead of reading raw `Meter` instruments because exporter configuration is an +operations concern, not a UI dependency. + Suggested service: ```csharp @@ -361,4 +367,3 @@ The first dashboard slice should implement: 8. workers page with worker table. 9. 1-second realtime refresh through Blazor Server. 10. redaction tests for secrets. - diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index d94acb9..f46854c 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -664,6 +664,26 @@ Metrics: Do not log credential values or full tag values by default. +The gateway registers `GatewayMetrics` as the in-process metrics foundation. +It emits .NET `Meter` instruments for collectors and keeps a +`GatewayMetricsSnapshot` for dashboard projection. The snapshot exists because +the dashboard needs current counters and queue depths without depending on a +specific metrics exporter. + +HTTP request handling uses `UseGatewayRequestLoggingScope()` to attach common +structured log fields when request metadata is present: + +- `SessionId`, +- `ClientIdentity`, +- `WorkerProcessId`, +- `CorrelationId`, +- `CommandMethod`. + +`GatewayLogRedactor` redacts API key secrets and command values before they are +added to log state. Value logging remains opt-in and redacted by default so +secured writes, authentication commands, and ordinary tag values do not leak +through diagnostics. + ## Configuration Suggested configuration shape: diff --git a/gateway.md b/gateway.md index 54778b3..a5156e0 100644 --- a/gateway.md +++ b/gateway.md @@ -97,6 +97,13 @@ Responsibilities: The gateway must never instantiate or call MXAccess directly. +The gateway observability foundation lives in `MxGateway.Server.Diagnostics` +and `MxGateway.Server.Metrics`. Structured logging scopes carry session, +worker, correlation, command, and client identity fields with redaction applied +before values enter log state. `GatewayMetrics` exposes counters, gauges, and +histograms through .NET `Meter` and a snapshot API that dashboard services can +project without binding to a metrics exporter. + ### Worker Process Runtime: diff --git a/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs b/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs new file mode 100644 index 0000000..2cf4d0a --- /dev/null +++ b/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs @@ -0,0 +1,78 @@ +namespace MxGateway.Server.Diagnostics; + +public static class GatewayLogRedactor +{ + public const string RedactedValue = "[redacted]"; + + private static readonly HashSet SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase) + { + "AuthenticateUser", + "WriteSecured", + "WriteSecured2" + }; + + public static bool IsCredentialBearingCommand(string? commandMethod) + { + return commandMethod is not null + && SensitiveCommandMethods.Contains(commandMethod); + } + + public static string? RedactApiKey(string? authorizationHeader) + { + if (string.IsNullOrWhiteSpace(authorizationHeader)) + { + return authorizationHeader; + } + + const string bearerPrefix = "Bearer "; + if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return RedactedValue; + } + + string token = authorizationHeader[bearerPrefix.Length..].Trim(); + + if (!token.StartsWith("mxgw_", StringComparison.OrdinalIgnoreCase)) + { + return $"{bearerPrefix}{RedactedValue}"; + } + + string[] tokenParts = token.Split('_', 3, StringSplitOptions.RemoveEmptyEntries); + if (tokenParts.Length < 2) + { + return $"{bearerPrefix}mxgw_{RedactedValue}"; + } + + return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}"; + } + + public static string? RedactClientIdentity(string? clientIdentity) + { + if (string.IsNullOrWhiteSpace(clientIdentity)) + { + return clientIdentity; + } + + return clientIdentity.Contains("mxgw_", StringComparison.OrdinalIgnoreCase) + ? RedactApiKey(clientIdentity) + : clientIdentity; + } + + public static object? RedactCommandValue( + string? commandMethod, + object? value, + bool valueLoggingEnabled = false) + { + if (value is null) + { + return null; + } + + if (!valueLoggingEnabled || IsCredentialBearingCommand(commandMethod)) + { + return RedactedValue; + } + + return value; + } +} diff --git a/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs b/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs new file mode 100644 index 0000000..d3c073c --- /dev/null +++ b/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs @@ -0,0 +1,33 @@ +namespace MxGateway.Server.Diagnostics; + +public sealed record GatewayLogScope( + string? SessionId = null, + int? WorkerProcessId = null, + ulong? CorrelationId = null, + string? CommandMethod = null, + string? ClientIdentity = null) +{ + public IReadOnlyDictionary ToDictionary() + { + Dictionary values = []; + + AddIfPresent(values, "SessionId", SessionId); + AddIfPresent(values, "WorkerProcessId", WorkerProcessId); + AddIfPresent(values, "CorrelationId", CorrelationId); + AddIfPresent(values, "CommandMethod", CommandMethod); + AddIfPresent(values, "ClientIdentity", GatewayLogRedactor.RedactClientIdentity(ClientIdentity)); + + return values; + } + + private static void AddIfPresent( + Dictionary values, + string key, + object? value) + { + if (value is not null) + { + values[key] = value; + } + } +} diff --git a/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs b/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs new file mode 100644 index 0000000..69ff90e --- /dev/null +++ b/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace MxGateway.Server.Diagnostics; + +public static class GatewayLoggerExtensions +{ + public static IDisposable? BeginGatewayScope( + this ILogger logger, + GatewayLogScope scope) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(scope); + + return logger.BeginScope(scope.ToDictionary()); + } +} diff --git a/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs b/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs new file mode 100644 index 0000000..8a86cff --- /dev/null +++ b/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Primitives; + +namespace MxGateway.Server.Diagnostics; + +public static class GatewayRequestLoggingMiddlewareExtensions +{ + public const string SessionIdHeaderName = "x-session-id"; + public const string WorkerProcessIdHeaderName = "x-worker-process-id"; + public const string CorrelationIdHeaderName = "x-correlation-id"; + public const string CommandMethodHeaderName = "x-command-method"; + + public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Use(async (context, next) => + { + ILogger logger = context.RequestServices + .GetRequiredService() + .CreateLogger("MxGateway.Request"); + + using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope( + SessionId: ReadHeader(context, SessionIdHeaderName), + WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName), + CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName), + CommandMethod: ReadHeader(context, CommandMethodHeaderName), + ClientIdentity: ReadHeader(context, "authorization"))); + + await next(context); + }); + } + + private static string? ReadHeader(HttpContext context, string headerName) + { + return context.Request.Headers.TryGetValue(headerName, out StringValues values) + ? values.ToString() + : null; + } + + private static int? ReadInt32Header(HttpContext context, string headerName) + { + string? value = ReadHeader(context, headerName); + + return int.TryParse(value, out int parsedValue) + ? parsedValue + : null; + } + + private static ulong? ReadUInt64Header(HttpContext context, string headerName) + { + string? value = ReadHeader(context, headerName); + + return ulong.TryParse(value, out ulong parsedValue) + ? parsedValue + : null; + } +} diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 648b537..fc6e609 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -1,4 +1,6 @@ using MxGateway.Contracts; +using MxGateway.Server.Diagnostics; +using MxGateway.Server.Metrics; namespace MxGateway.Server; @@ -9,6 +11,7 @@ public static class GatewayApplication WebApplicationBuilder builder = CreateBuilder(args); WebApplication app = builder.Build(); + app.UseGatewayRequestLoggingScope(); app.MapGatewayEndpoints(); return app; @@ -19,6 +22,7 @@ public static class GatewayApplication WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHealthChecks(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/MxGateway.Server/Metrics/GatewayMetrics.cs b/src/MxGateway.Server/Metrics/GatewayMetrics.cs new file mode 100644 index 0000000..c377401 --- /dev/null +++ b/src/MxGateway.Server/Metrics/GatewayMetrics.cs @@ -0,0 +1,306 @@ +using System.Diagnostics.Metrics; + +namespace MxGateway.Server.Metrics; + +public sealed class GatewayMetrics : IDisposable +{ + public const string MeterName = "MxGateway.Server"; + + private readonly object _syncRoot = new(); + private readonly Meter _meter; + private readonly Counter _sessionsOpenedCounter; + private readonly Counter _sessionsClosedCounter; + private readonly Counter _commandsStartedCounter; + private readonly Counter _commandsSucceededCounter; + private readonly Counter _commandsFailedCounter; + private readonly Counter _eventsReceivedCounter; + private readonly Counter _queueOverflowsCounter; + private readonly Counter _faultsCounter; + private readonly Counter _workerKillsCounter; + private readonly Counter _workerExitsCounter; + private readonly Counter _heartbeatFailuresCounter; + private readonly Counter _streamDisconnectsCounter; + private readonly Histogram _workerStartupLatencyHistogram; + private readonly Histogram _commandLatencyHistogram; + private readonly Histogram _eventStreamSendLatencyHistogram; + private readonly Dictionary _commandFailuresByMethod = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _eventsByFamily = new(StringComparer.OrdinalIgnoreCase); + + private int _openSessions; + private int _workersRunning; + private int _eventQueueDepth; + private long _sessionsOpened; + private long _sessionsClosed; + private long _commandsStarted; + private long _commandsSucceeded; + private long _commandsFailed; + private long _eventsReceived; + private long _queueOverflows; + private long _faults; + private long _workerKills; + private long _workerExits; + private long _heartbeatFailures; + private long _streamDisconnects; + private bool _disposed; + + public GatewayMetrics() + { + _meter = new Meter(MeterName, typeof(GatewayMetrics).Assembly.GetName().Version?.ToString()); + _sessionsOpenedCounter = _meter.CreateCounter("mxgateway.sessions.opened"); + _sessionsClosedCounter = _meter.CreateCounter("mxgateway.sessions.closed"); + _commandsStartedCounter = _meter.CreateCounter("mxgateway.commands.started"); + _commandsSucceededCounter = _meter.CreateCounter("mxgateway.commands.succeeded"); + _commandsFailedCounter = _meter.CreateCounter("mxgateway.commands.failed"); + _eventsReceivedCounter = _meter.CreateCounter("mxgateway.events.received"); + _queueOverflowsCounter = _meter.CreateCounter("mxgateway.queues.overflows"); + _faultsCounter = _meter.CreateCounter("mxgateway.faults"); + _workerKillsCounter = _meter.CreateCounter("mxgateway.workers.killed"); + _workerExitsCounter = _meter.CreateCounter("mxgateway.workers.exited"); + _heartbeatFailuresCounter = _meter.CreateCounter("mxgateway.heartbeats.failed"); + _streamDisconnectsCounter = _meter.CreateCounter("mxgateway.grpc.streams.disconnected"); + _workerStartupLatencyHistogram = _meter.CreateHistogram("mxgateway.workers.startup.duration", "ms"); + _commandLatencyHistogram = _meter.CreateHistogram("mxgateway.commands.duration", "ms"); + _eventStreamSendLatencyHistogram = _meter.CreateHistogram("mxgateway.events.stream_send.duration", "ms"); + + _meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions); + _meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning); + _meter.CreateObservableGauge("mxgateway.events.queue.depth", GetEventQueueDepth); + } + + public void SessionOpened() + { + lock (_syncRoot) + { + _openSessions++; + _sessionsOpened++; + } + + _sessionsOpenedCounter.Add(1); + } + + public void SessionClosed() + { + lock (_syncRoot) + { + if (_openSessions > 0) + { + _openSessions--; + } + + _sessionsClosed++; + } + + _sessionsClosedCounter.Add(1); + } + + public void WorkerStarted(TimeSpan startupDuration) + { + lock (_syncRoot) + { + _workersRunning++; + } + + _workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds); + } + + public void WorkerStopped(string reason) + { + lock (_syncRoot) + { + if (_workersRunning > 0) + { + _workersRunning--; + } + + _workerExits++; + } + + _workerExitsCounter.Add(1, new KeyValuePair("reason", reason)); + } + + public void WorkerKilled(string reason) + { + lock (_syncRoot) + { + _workerKills++; + } + + _workerKillsCounter.Add(1, new KeyValuePair("reason", reason)); + } + + public void CommandStarted(string method) + { + lock (_syncRoot) + { + _commandsStarted++; + } + + _commandsStartedCounter.Add(1, new KeyValuePair("method", method)); + } + + public void CommandSucceeded(string method, TimeSpan duration) + { + lock (_syncRoot) + { + _commandsSucceeded++; + } + + KeyValuePair methodTag = new("method", method); + _commandsSucceededCounter.Add(1, methodTag); + _commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag); + } + + public void CommandFailed(string method, string category, TimeSpan duration) + { + lock (_syncRoot) + { + _commandsFailed++; + Increment(_commandFailuresByMethod, method); + } + + KeyValuePair methodTag = new("method", method); + KeyValuePair categoryTag = new("category", category); + _commandsFailedCounter.Add(1, methodTag, categoryTag); + _commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag); + } + + public void EventReceived(string sessionId, string family) + { + lock (_syncRoot) + { + _eventsReceived++; + Increment(_eventsByFamily, family); + } + + _eventsReceivedCounter.Add( + 1, + new KeyValuePair("session_id", sessionId), + new KeyValuePair("family", family)); + } + + public void RecordEventStreamSend(string family, TimeSpan duration) + { + _eventStreamSendLatencyHistogram.Record( + duration.TotalMilliseconds, + new KeyValuePair("family", family)); + } + + public void SetEventQueueDepth(int depth) + { + if (depth < 0) + { + throw new ArgumentOutOfRangeException(nameof(depth), depth, "Queue depth cannot be negative."); + } + + lock (_syncRoot) + { + _eventQueueDepth = depth; + } + } + + public void QueueOverflow(string queueName) + { + lock (_syncRoot) + { + _queueOverflows++; + } + + _queueOverflowsCounter.Add(1, new KeyValuePair("queue", queueName)); + } + + public void Fault(string category) + { + lock (_syncRoot) + { + _faults++; + } + + _faultsCounter.Add(1, new KeyValuePair("category", category)); + } + + public void HeartbeatFailed(string sessionId) + { + lock (_syncRoot) + { + _heartbeatFailures++; + } + + _heartbeatFailuresCounter.Add(1, new KeyValuePair("session_id", sessionId)); + } + + public void StreamDisconnected(string reason) + { + lock (_syncRoot) + { + _streamDisconnects++; + } + + _streamDisconnectsCounter.Add(1, new KeyValuePair("reason", reason)); + } + + public GatewayMetricsSnapshot GetSnapshot() + { + lock (_syncRoot) + { + return new GatewayMetricsSnapshot( + OpenSessions: _openSessions, + WorkersRunning: _workersRunning, + EventQueueDepth: _eventQueueDepth, + SessionsOpened: _sessionsOpened, + SessionsClosed: _sessionsClosed, + CommandsStarted: _commandsStarted, + CommandsSucceeded: _commandsSucceeded, + CommandsFailed: _commandsFailed, + EventsReceived: _eventsReceived, + QueueOverflows: _queueOverflows, + Faults: _faults, + WorkerKills: _workerKills, + WorkerExits: _workerExits, + HeartbeatFailures: _heartbeatFailures, + StreamDisconnects: _streamDisconnects, + CommandFailuresByMethod: new Dictionary(_commandFailuresByMethod, StringComparer.OrdinalIgnoreCase), + EventsByFamily: new Dictionary(_eventsByFamily, StringComparer.OrdinalIgnoreCase)); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _meter.Dispose(); + _disposed = true; + } + + private int GetOpenSessions() + { + lock (_syncRoot) + { + return _openSessions; + } + } + + private int GetWorkersRunning() + { + lock (_syncRoot) + { + return _workersRunning; + } + } + + private int GetEventQueueDepth() + { + lock (_syncRoot) + { + return _eventQueueDepth; + } + } + + private static void Increment(Dictionary values, string key) + { + values.TryGetValue(key, out long currentValue); + values[key] = currentValue + 1; + } +} diff --git a/src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs b/src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs new file mode 100644 index 0000000..e00d07e --- /dev/null +++ b/src/MxGateway.Server/Metrics/GatewayMetricsSnapshot.cs @@ -0,0 +1,20 @@ +namespace MxGateway.Server.Metrics; + +public sealed record GatewayMetricsSnapshot( + int OpenSessions, + int WorkersRunning, + int EventQueueDepth, + long SessionsOpened, + long SessionsClosed, + long CommandsStarted, + long CommandsSucceeded, + long CommandsFailed, + long EventsReceived, + long QueueOverflows, + long Faults, + long WorkerKills, + long WorkerExits, + long HeartbeatFailures, + long StreamDisconnects, + IReadOnlyDictionary CommandFailuresByMethod, + IReadOnlyDictionary EventsByFamily); diff --git a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs b/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs new file mode 100644 index 0000000..5f2c16f --- /dev/null +++ b/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs @@ -0,0 +1,73 @@ +using MxGateway.Server.Diagnostics; + +namespace MxGateway.Tests.Diagnostics; + +public sealed class GatewayLogRedactorTests +{ + [Fact] + public void RedactApiKey_PreservesKeyIdAndRemovesSecret() + { + string? redacted = GatewayLogRedactor.RedactApiKey("Bearer mxgw_operator01_super-secret"); + + Assert.Equal("Bearer mxgw_operator01_[redacted]", redacted); + Assert.DoesNotContain("super-secret", redacted); + } + + [Theory] + [InlineData("AuthenticateUser")] + [InlineData("WriteSecured")] + [InlineData("WriteSecured2")] + public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod) + { + Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod)); + } + + [Fact] + public void RedactCommandValue_DoesNotLogRawValuesByDefault() + { + object? redacted = GatewayLogRedactor.RedactCommandValue("Write", "plaintext-tag-value"); + + Assert.Equal("[redacted]", redacted); + } + + [Fact] + public void RedactCommandValue_RedactsSecuredWriteEvenWhenValueLoggingIsEnabled() + { + object? redacted = GatewayLogRedactor.RedactCommandValue( + "WriteSecured", + "credential-bearing-value", + valueLoggingEnabled: true); + + Assert.Equal("[redacted]", redacted); + } + + [Fact] + public void RedactCommandValue_AllowsNonSensitiveValueOnlyWhenValueLoggingIsEnabled() + { + object? redacted = GatewayLogRedactor.RedactCommandValue( + "Write", + "diagnostic-value", + valueLoggingEnabled: true); + + Assert.Equal("diagnostic-value", redacted); + } + + [Fact] + public void LogScope_RedactsClientIdentityBeforeScopeStateIsCreated() + { + GatewayLogScope scope = new( + SessionId: "session-1", + WorkerProcessId: 1234, + CorrelationId: 99, + CommandMethod: "AuthenticateUser", + ClientIdentity: "Bearer mxgw_admin_secret"); + + IReadOnlyDictionary values = scope.ToDictionary(); + + Assert.Equal("session-1", values["SessionId"]); + Assert.Equal(1234, values["WorkerProcessId"]); + Assert.Equal((ulong)99, values["CorrelationId"]); + Assert.Equal("AuthenticateUser", values["CommandMethod"]); + Assert.Equal("Bearer mxgw_admin_[redacted]", values["ClientIdentity"]); + } +} diff --git a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 006798f..c345d1f 100644 --- a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using MxGateway.Server; +using MxGateway.Server.Metrics; namespace MxGateway.Tests.Gateway; @@ -19,4 +21,14 @@ public sealed class GatewayApplicationTests Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata()?.EndpointName); } + + [Fact] + public void Build_RegistersGatewayMetrics() + { + WebApplication app = GatewayApplication.Build([]); + + GatewayMetrics metrics = app.Services.GetRequiredService(); + + Assert.NotNull(metrics); + } } diff --git a/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs b/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs new file mode 100644 index 0000000..f4953cd --- /dev/null +++ b/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs @@ -0,0 +1,60 @@ +using MxGateway.Server.Metrics; + +namespace MxGateway.Tests.Metrics; + +public sealed class GatewayMetricsTests +{ + [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"); + metrics.SetEventQueueDepth(7); + 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.EventQueueDepth); + 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"]); + } + + [Fact] + public void SetEventQueueDepth_RejectsNegativeDepth() + { + using GatewayMetrics metrics = new(); + + ArgumentOutOfRangeException exception = Assert.Throws( + () => metrics.SetEventQueueDepth(-1)); + + Assert.Equal("depth", exception.ParamName); + } +}