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