// 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); } }