namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Metrics;
///
/// Lock-free logarithmic latency histogram sized for tens of thousands of samples
/// per second across many threads.
///
///
/// Buckets are 16-per-octave over microseconds, i.e. bucket i covers
/// [2^(i/16), 2^((i+1)/16)) µs. That bounds relative bucket width at
/// 2^(1/16) - 1 ≈ 4.4%, so a reported percentile is within ~4.4% of the true
/// value — ample for the millisecond-scale thresholds this harness asserts, and far
/// cheaper than retaining 45 million raw samples.
///
///
/// Recording is a plus one
/// ; there is no allocation on the hot path.
///
///
public sealed class LatencyHistogram
{
private const int SubBucketsPerOctave = 16;
private const int BucketCount = 64 * SubBucketsPerOctave;
private readonly long[] _buckets = new long[BucketCount];
private long _count;
private long _totalMicroseconds;
private long _maxMicroseconds;
/// Number of samples recorded.
public long Count => Interlocked.Read(ref _count);
/// Largest sample seen, in microseconds (exact — not bucketed).
public double MaxMs => Interlocked.Read(ref _maxMicroseconds) / 1000.0;
/// Arithmetic mean in milliseconds (exact — accumulated, not bucketed).
public double MeanMs
{
get
{
var count = Interlocked.Read(ref _count);
return count == 0 ? 0 : Interlocked.Read(ref _totalMicroseconds) / 1000.0 / count;
}
}
///
/// Records one sample. Negative durations (clock skew across the emit/receive
/// boundary) are clamped to zero rather than discarded, so the sample count stays
/// an honest denominator.
///
/// The measured latency.
public void Record(TimeSpan elapsed)
{
var micros = (long)(elapsed.TotalMilliseconds * 1000.0);
if (micros < 0) micros = 0;
Interlocked.Increment(ref _count);
Interlocked.Add(ref _totalMicroseconds, micros);
long observedMax;
while (micros > (observedMax = Interlocked.Read(ref _maxMicroseconds)))
{
if (Interlocked.CompareExchange(ref _maxMicroseconds, micros, observedMax) == observedMax)
break;
}
Interlocked.Increment(ref _buckets[BucketIndex(micros)]);
}
private static int BucketIndex(long micros)
{
if (micros <= 0) return 0;
var index = (int)(Math.Log2(micros) * SubBucketsPerOctave);
if (index < 0) return 0;
return index >= BucketCount ? BucketCount - 1 : index;
}
/// Bucket midpoint in milliseconds, used when reconstructing a percentile.
private static double BucketMidpointMs(int index)
{
var low = Math.Pow(2, (double)index / SubBucketsPerOctave);
var high = Math.Pow(2, (double)(index + 1) / SubBucketsPerOctave);
return (low + high) / 2.0 / 1000.0;
}
///
/// Returns the requested percentile in milliseconds, or 0 when no samples were recorded.
///
/// Percentile in the range 0..100 (e.g. 99 for P99).
/// The percentile value in milliseconds.
public double PercentileMs(double percentile)
{
var total = Interlocked.Read(ref _count);
if (total == 0) return 0;
var target = (long)Math.Ceiling(total * percentile / 100.0);
if (target < 1) target = 1;
long cumulative = 0;
for (var i = 0; i < BucketCount; i++)
{
cumulative += Interlocked.Read(ref _buckets[i]);
if (cumulative >= target)
return BucketMidpointMs(i);
}
return MaxMs;
}
/// Materializes the standard percentile set plus mean/max/count for reporting.
/// A snapshot record of this histogram.
public LatencySnapshot Snapshot() => new(
Count,
MeanMs,
PercentileMs(50),
PercentileMs(95),
PercentileMs(99),
PercentileMs(99.9),
MaxMs);
}
/// Point-in-time summary of a . All times in milliseconds.
/// Samples recorded.
/// Arithmetic mean.
/// Median.
/// 95th percentile.
/// 99th percentile.
/// 99.9th percentile.
/// Largest observed sample.
public sealed record LatencySnapshot(
long Count,
double MeanMs,
double P50Ms,
double P95Ms,
double P99Ms,
double P999Ms,
double MaxMs);