Issue #4: add structured logging and metrics foundation

This commit is contained in:
Joseph Doherty
2026-04-26 16:10:58 -04:00
parent 16c18954b6
commit 7dfec6dc8c
13 changed files with 692 additions and 1 deletions
@@ -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<string, object?> 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"]);
}
}
@@ -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<IEndpointNameMetadata>()?.EndpointName);
}
[Fact]
public void Build_RegistersGatewayMetrics()
{
WebApplication app = GatewayApplication.Build([]);
GatewayMetrics metrics = app.Services.GetRequiredService<GatewayMetrics>();
Assert.NotNull(metrics);
}
}
@@ -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<ArgumentOutOfRangeException>(
() => metrics.SetEventQueueDepth(-1));
Assert.Equal("depth", exception.ParamName);
}
}