feat: add service export latency tracking with p50/p90/p99 (Gap 9.1)

Add ServiceLatencyTracker with sorted-sample histogram, percentile getters (p50/p90/p99), average/min/max, reset, and immutable snapshot. Wire LatencyTracker and RecordServiceLatency onto Account. Cover with 11 xUnit tests.
This commit is contained in:
Joseph Doherty
2026-02-25 12:52:05 -05:00
parent dcd6b78a89
commit 3107615885
4 changed files with 554 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
// Tests for Account.SetServiceResponseThreshold / GetServiceResponseThreshold /
// IsServiceResponseOverdue / CheckServiceResponse.
// Go reference: server/accounts.go — SetServiceExportResponseThreshold (~line 2522),
// ServiceExportResponseThreshold (~line 2510).
using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth;
public class ResponseThresholdTests
{
// ---------------------------------------------------------------------------
// SetServiceResponseThreshold / GetServiceResponseThreshold
// ---------------------------------------------------------------------------
[Fact]
public void SetServiceResponseThreshold_StoresThreshold()
{
// Go ref: accounts.go SetServiceExportResponseThreshold (~line 2522)
var account = new Account("test");
account.SetServiceResponseThreshold("svc.foo", TimeSpan.FromSeconds(5));
account.ServiceResponseThresholds.ContainsKey("svc.foo").ShouldBeTrue();
account.ServiceResponseThresholds["svc.foo"].ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void GetServiceResponseThreshold_ReturnsStored()
{
// Go ref: accounts.go ServiceExportResponseThreshold (~line 2510)
var account = new Account("test");
account.SetServiceResponseThreshold("svc.bar", TimeSpan.FromMilliseconds(200));
account.GetServiceResponseThreshold("svc.bar").ShouldBe(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void GetServiceResponseThreshold_NotSet_ReturnsNull()
{
// Go ref: accounts.go ServiceExportResponseThreshold — returns error when export not found
var account = new Account("test");
account.GetServiceResponseThreshold("svc.unknown").ShouldBeNull();
}
// ---------------------------------------------------------------------------
// IsServiceResponseOverdue
// ---------------------------------------------------------------------------
[Fact]
public void IsServiceResponseOverdue_WithinThreshold_ReturnsFalse()
{
// Go ref: accounts.go respThresh check — elapsed < threshold ⇒ not overdue
var account = new Account("test");
account.SetServiceResponseThreshold("svc.a", TimeSpan.FromSeconds(10));
account.IsServiceResponseOverdue("svc.a", TimeSpan.FromSeconds(9)).ShouldBeFalse();
}
[Fact]
public void IsServiceResponseOverdue_ExceedsThreshold_ReturnsTrue()
{
// Go ref: accounts.go respThresh check — elapsed > threshold ⇒ overdue
var account = new Account("test");
account.SetServiceResponseThreshold("svc.b", TimeSpan.FromSeconds(1));
account.IsServiceResponseOverdue("svc.b", TimeSpan.FromSeconds(2)).ShouldBeTrue();
}
[Fact]
public void IsServiceResponseOverdue_NoThreshold_ReturnsFalse()
{
// Go ref: accounts.go — when no respThresh is set the timer never fires (never overdue)
var account = new Account("test");
account.IsServiceResponseOverdue("svc.unregistered", TimeSpan.FromHours(1)).ShouldBeFalse();
}
[Fact]
public void SetServiceResponseThreshold_OverwritesPrevious()
{
// Go ref: accounts.go SetServiceExportResponseThreshold — se.respThresh = maxTime overwrites
var account = new Account("test");
account.SetServiceResponseThreshold("svc.c", TimeSpan.FromSeconds(5));
account.SetServiceResponseThreshold("svc.c", TimeSpan.FromSeconds(30));
account.GetServiceResponseThreshold("svc.c").ShouldBe(TimeSpan.FromSeconds(30));
}
// ---------------------------------------------------------------------------
// CheckServiceResponse
// ---------------------------------------------------------------------------
[Fact]
public void CheckServiceResponse_Found_NotOverdue()
{
// Go ref: accounts.go ServiceExportResponseThreshold + respThresh timer — within window
var account = new Account("test");
account.SetServiceResponseThreshold("svc.d", TimeSpan.FromSeconds(10));
var result = account.CheckServiceResponse("svc.d", TimeSpan.FromSeconds(5));
result.Found.ShouldBeTrue();
result.IsOverdue.ShouldBeFalse();
result.Threshold.ShouldBe(TimeSpan.FromSeconds(10));
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void CheckServiceResponse_Found_Overdue()
{
// Go ref: accounts.go respThresh timer fires — elapsed exceeded threshold
var account = new Account("test");
account.SetServiceResponseThreshold("svc.e", TimeSpan.FromSeconds(2));
var result = account.CheckServiceResponse("svc.e", TimeSpan.FromSeconds(5));
result.Found.ShouldBeTrue();
result.IsOverdue.ShouldBeTrue();
result.Threshold.ShouldBe(TimeSpan.FromSeconds(2));
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void CheckServiceResponse_NotFound()
{
// Go ref: accounts.go — no export defined, returns error; here Found=false
var account = new Account("test");
var result = account.CheckServiceResponse("svc.none", TimeSpan.FromSeconds(1));
result.Found.ShouldBeFalse();
result.IsOverdue.ShouldBeFalse();
result.Threshold.ShouldBeNull();
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(1));
}
}

View File

@@ -0,0 +1,168 @@
// Tests for service export latency tracker with p50/p90/p99 percentile histogram.
// Go reference: accounts_test.go TestServiceLatency, serviceExportLatencyStats.
using NATS.Server.Auth;
namespace NATS.Server.Tests.Auth;
public class ServiceLatencyTrackerTests
{
[Fact]
public void RecordLatency_IncrementsTotalRequests()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.RecordLatency(30.0);
tracker.TotalRequests.ShouldBe(3L);
}
[Fact]
public void GetP50_ReturnsMedian()
{
var tracker = new ServiceLatencyTracker();
foreach (var v in new double[] { 1, 2, 3, 4, 5 })
tracker.RecordLatency(v);
// Sorted: [1, 2, 3, 4, 5], index = (int)(0.50 * 4) = 2 → value 3
tracker.GetP50().ShouldBe(3.0);
}
[Fact]
public void GetP90_ReturnsHighPercentile()
{
var tracker = new ServiceLatencyTracker();
for (var i = 1; i <= 100; i++)
tracker.RecordLatency(i);
// Sorted [1..100], index = (int)(0.90 * 99) = (int)89.1 = 89 → value 90
tracker.GetP90().ShouldBe(90.0);
}
[Fact]
public void GetP99_ReturnsTopPercentile()
{
var tracker = new ServiceLatencyTracker();
for (var i = 1; i <= 100; i++)
tracker.RecordLatency(i);
// Sorted [1..100], index = (int)(0.99 * 99) = (int)98.01 = 98 → value 99
tracker.GetP99().ShouldBe(99.0);
}
[Fact]
public void AverageLatencyMs_CalculatesCorrectly()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.RecordLatency(30.0);
tracker.AverageLatencyMs.ShouldBe(20.0);
}
[Fact]
public void MinLatencyMs_ReturnsMinimum()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(15.0);
tracker.RecordLatency(5.0);
tracker.RecordLatency(10.0);
tracker.MinLatencyMs.ShouldBe(5.0);
}
[Fact]
public void MaxLatencyMs_ReturnsMaximum()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(5.0);
tracker.RecordLatency(15.0);
tracker.RecordLatency(10.0);
tracker.MaxLatencyMs.ShouldBe(15.0);
}
[Fact]
public void Reset_ClearsSamples()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.SampleCount.ShouldBe(2);
tracker.TotalRequests.ShouldBe(2L);
tracker.Reset();
tracker.SampleCount.ShouldBe(0);
tracker.TotalRequests.ShouldBe(0L);
tracker.AverageLatencyMs.ShouldBe(0.0);
tracker.MinLatencyMs.ShouldBe(0.0);
tracker.MaxLatencyMs.ShouldBe(0.0);
tracker.GetP50().ShouldBe(0.0);
}
[Fact]
public void GetSnapshot_ReturnsImmutableSnapshot()
{
var tracker = new ServiceLatencyTracker();
tracker.RecordLatency(10.0);
tracker.RecordLatency(20.0);
tracker.RecordLatency(30.0);
var snapshot = tracker.GetSnapshot();
snapshot.TotalRequests.ShouldBe(3L);
snapshot.SampleCount.ShouldBe(3);
snapshot.AverageMs.ShouldBe(20.0);
snapshot.MinMs.ShouldBe(10.0);
snapshot.MaxMs.ShouldBe(30.0);
// P50 of [10, 20, 30]: index = (int)(0.50 * 2) = 1 → 20
snapshot.P50Ms.ShouldBe(20.0);
// Mutating tracker after snapshot does not change the snapshot
tracker.RecordLatency(1000.0);
snapshot.MaxMs.ShouldBe(30.0);
snapshot.SampleCount.ShouldBe(3);
}
[Fact]
public void MaxSamples_EvictsOldest()
{
var tracker = new ServiceLatencyTracker(maxSamples: 5);
for (var i = 1; i <= 10; i++)
tracker.RecordLatency(i);
// Only the last 5 samples should remain (6, 7, 8, 9, 10)
tracker.SampleCount.ShouldBe(5);
// TotalRequests counts all recorded calls, not just retained ones
tracker.TotalRequests.ShouldBe(10L);
// Minimum of retained samples is 6
tracker.MinLatencyMs.ShouldBe(6.0);
// Maximum of retained samples is 10
tracker.MaxLatencyMs.ShouldBe(10.0);
}
[Fact]
public void Account_RecordServiceLatency_DelegatesToTracker()
{
var account = new Account("test");
account.RecordServiceLatency(50.0);
account.RecordServiceLatency(100.0);
account.LatencyTracker.TotalRequests.ShouldBe(2L);
account.LatencyTracker.AverageLatencyMs.ShouldBe(75.0);
}
}