feat(lmxproxy): phase 4 — host health monitoring, metrics, status web server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 00:14:40 -04:00
parent 16d1b95e9a
commit 9eb81180c0
12 changed files with 1546 additions and 12 deletions

View File

@@ -0,0 +1,147 @@
using System;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Host.Metrics;
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics
{
public class PerformanceMetricsTests
{
[Fact]
public void RecordOperation_TracksCountAndDuration()
{
using var metrics = new PerformanceMetrics();
for (int i = 0; i < 5; i++)
{
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true);
}
var stats = metrics.GetStatistics();
stats.Should().ContainKey("TestOp");
stats["TestOp"].TotalCount.Should().Be(5);
}
[Fact]
public void RecordOperation_TracksSuccessAndFailure()
{
using var metrics = new PerformanceMetrics();
for (int i = 0; i < 3; i++)
{
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true);
}
for (int i = 0; i < 2; i++)
{
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), false);
}
var stats = metrics.GetStatistics();
stats["TestOp"].SuccessRate.Should().BeApproximately(0.6, 0.001);
}
[Fact]
public void GetStatistics_CalculatesP95Correctly()
{
using var metrics = new PerformanceMetrics();
for (int i = 1; i <= 100; i++)
{
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true);
}
var stats = metrics.GetStatistics();
stats["TestOp"].Percentile95Milliseconds.Should().BeApproximately(95.0, 1.0);
}
[Fact]
public void RollingBuffer_CapsAt1000Samples()
{
using var metrics = new PerformanceMetrics();
for (int i = 0; i < 1500; i++)
{
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true);
}
var stats = metrics.GetStatistics();
// TotalCount tracks all 1500 but percentile is computed from the last 1000
stats["TestOp"].TotalCount.Should().Be(1500);
// The rolling buffer should have entries from 500-1499
// P95 of 500..1499 should be around 1449
stats["TestOp"].Percentile95Milliseconds.Should().BeGreaterThan(1000);
}
[Fact]
public void BeginOperation_RecordsDurationOnDispose()
{
using var metrics = new PerformanceMetrics();
using (var scope = metrics.BeginOperation("TestOp"))
{
System.Threading.Thread.Sleep(50);
}
var stats = metrics.GetStatistics();
stats.Should().ContainKey("TestOp");
stats["TestOp"].TotalCount.Should().Be(1);
stats["TestOp"].AverageMilliseconds.Should().BeGreaterOrEqualTo(40);
}
[Fact]
public void TimingScope_DefaultsToSuccess()
{
using var metrics = new PerformanceMetrics();
using (metrics.BeginOperation("TestOp"))
{
// Do nothing — default is success
}
var stats = metrics.GetStatistics();
stats["TestOp"].SuccessCount.Should().Be(1);
}
[Fact]
public void TimingScope_RespectsSetSuccessFalse()
{
using var metrics = new PerformanceMetrics();
using (var scope = metrics.BeginOperation("TestOp"))
{
scope.SetSuccess(false);
}
var stats = metrics.GetStatistics();
stats["TestOp"].SuccessCount.Should().Be(0);
stats["TestOp"].TotalCount.Should().Be(1);
}
[Fact]
public void GetMetrics_ReturnsNullForUnknownOperation()
{
using var metrics = new PerformanceMetrics();
var result = metrics.GetMetrics("DoesNotExist");
result.Should().BeNull();
}
[Fact]
public void GetAllMetrics_ReturnsAllTrackedOperations()
{
using var metrics = new PerformanceMetrics();
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20), true);
metrics.RecordOperation("Subscribe", TimeSpan.FromMilliseconds(5), true);
var all = metrics.GetAllMetrics();
all.Should().ContainKey("Read");
all.Should().ContainKey("Write");
all.Should().ContainKey("Subscribe");
all.Count.Should().Be(3);
}
}
}