feat: add sample/observe mode with latency measurement (Gap 3.11)

Implements SampleTracker with stochastic delivery sampling (ParseSampleFrequency,
ShouldSample, RecordLatency) and LatencySample for consumer observability advisories.
Ports consumer.go sampleFrequency / shouldSample / parseSampleFrequency logic.
15 new tests covering parsing, rate extremes, deterministic seeded sampling, and field capture.
This commit is contained in:
Joseph Doherty
2026-02-25 11:14:58 -05:00
parent 8b4b236968
commit b9aa62ae99
2 changed files with 285 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
using NATS.Server.JetStream.Consumers;
namespace NATS.Server.Tests.JetStream.Consumers;
/// <summary>
/// Tests for SampleTracker: sample frequency parsing and stochastic latency sampling.
/// Go reference: consumer.go sampleFrequency, shouldSample, parseSampleFrequency.
/// </summary>
public class SampleModeTests
{
// --- ParseSampleFrequency ---
[Fact]
public void ParseSampleFrequency_one_percent()
{
var rate = SampleTracker.ParseSampleFrequency("1%");
rate.ShouldBe(0.01, 1e-9);
}
[Fact]
public void ParseSampleFrequency_fifty_percent()
{
var rate = SampleTracker.ParseSampleFrequency("50%");
rate.ShouldBe(0.5, 1e-9);
}
[Fact]
public void ParseSampleFrequency_hundred_percent()
{
var rate = SampleTracker.ParseSampleFrequency("100%");
rate.ShouldBe(1.0, 1e-9);
}
[Fact]
public void ParseSampleFrequency_zero()
{
var rate = SampleTracker.ParseSampleFrequency("0%");
rate.ShouldBe(0.0, 1e-9);
}
[Fact]
public void ParseSampleFrequency_no_percent_sign()
{
var rate = SampleTracker.ParseSampleFrequency("25");
rate.ShouldBe(0.25, 1e-9);
}
[Fact]
public void ParseSampleFrequency_empty_string()
{
var rate = SampleTracker.ParseSampleFrequency("");
rate.ShouldBe(0.0, 1e-9);
}
[Fact]
public void ParseSampleFrequency_null()
{
var rate = SampleTracker.ParseSampleFrequency(null);
rate.ShouldBe(0.0, 1e-9);
}
[Fact]
public void ParseSampleFrequency_invalid()
{
var rate = SampleTracker.ParseSampleFrequency("abc");
rate.ShouldBe(0.0, 1e-9);
}
[Fact]
public void ParseSampleFrequency_over_100_clamped()
{
var rate = SampleTracker.ParseSampleFrequency("200%");
rate.ShouldBe(1.0, 1e-9);
}
// --- ShouldSample ---
[Fact]
public void ShouldSample_rate_100_always_samples()
{
var tracker = new SampleTracker(1.0);
for (var i = 0; i < 20; i++)
{
tracker.ShouldSample().ShouldBeTrue();
}
}
[Fact]
public void ShouldSample_rate_0_never_samples()
{
var tracker = new SampleTracker(0.0);
for (var i = 0; i < 20; i++)
{
tracker.ShouldSample().ShouldBeFalse();
}
}
[Fact]
public void ShouldSample_increments_total_deliveries()
{
var tracker = new SampleTracker(0.5);
tracker.TotalDeliveries.ShouldBe(0L);
tracker.ShouldSample();
tracker.TotalDeliveries.ShouldBe(1L);
tracker.ShouldSample();
tracker.TotalDeliveries.ShouldBe(2L);
tracker.ShouldSample();
tracker.TotalDeliveries.ShouldBe(3L);
}
[Fact]
public void ShouldSample_stochastic_with_seeded_random()
{
// Use a seeded Random for deterministic results.
// With seed 42 and rate 0.5, we can predict exact outcomes.
var rng = new Random(42);
var tracker = new SampleTracker(0.5, rng);
// Pre-compute expected outcomes using the same seed.
var expectedRng = new Random(42);
var expected = new bool[10];
for (var i = 0; i < 10; i++)
{
expected[i] = expectedRng.NextDouble() < 0.5;
}
var actual = new bool[10];
for (var i = 0; i < 10; i++)
{
actual[i] = tracker.ShouldSample();
}
actual.ShouldBe(expected);
tracker.TotalDeliveries.ShouldBe(10L);
}
[Fact]
public void RecordLatency_captures_all_fields()
{
var tracker = new SampleTracker(1.0);
var latency = TimeSpan.FromMilliseconds(42);
const ulong seq = 7UL;
const string subject = "orders.new";
var before = DateTime.UtcNow;
var sample = tracker.RecordLatency(latency, seq, subject);
var after = DateTime.UtcNow;
sample.Sequence.ShouldBe(seq);
sample.Subject.ShouldBe(subject);
sample.DeliveryLatency.ShouldBe(latency);
sample.SampledAtUtc.ShouldBeGreaterThanOrEqualTo(before);
sample.SampledAtUtc.ShouldBeLessThanOrEqualTo(after);
}
[Fact]
public void SampleCount_tracks_sampled_only()
{
// Rate 1.0: every delivery is sampled.
var allSampled = new SampleTracker(1.0);
for (var i = 0; i < 5; i++) allSampled.ShouldSample();
allSampled.SampleCount.ShouldBe(5L);
allSampled.TotalDeliveries.ShouldBe(5L);
// Rate 0.0: no delivery is sampled.
var noneSampled = new SampleTracker(0.0);
for (var i = 0; i < 5; i++) noneSampled.ShouldSample();
noneSampled.SampleCount.ShouldBe(0L);
noneSampled.TotalDeliveries.ShouldBe(5L);
}
}