Skip to main content

fast_telemetry/metric/
histogram.rs

1//! Fixed-bucket histogram using sharded counters.
2//!
3//! Each bucket is a thread-sharded `Counter`, so recording is contention-free.
4//! Bucket boundaries are defined at construction time.
5
6use crate::Counter;
7
8/// A fixed-bucket histogram with thread-sharded counters.
9///
10/// Buckets are defined by upper bounds (inclusive). Values are placed in the
11/// first bucket whose bound is >= the value. Values exceeding all bounds go
12/// in the final "+Inf" bucket.
13pub struct Histogram {
14    /// Upper bounds for each bucket (last is always +Inf conceptually)
15    bounds: Vec<u64>,
16    /// One sharded counter per bucket, plus one for +Inf
17    buckets: Vec<Counter>,
18    /// Sum of all recorded values (for computing mean)
19    sum: Counter,
20    /// Total count (for Prometheus _count)
21    count: Counter,
22}
23
24impl Histogram {
25    /// Create a histogram with the given bucket boundaries.
26    ///
27    /// Boundaries should be sorted ascending. Each boundary represents the
28    /// upper bound (inclusive) of a bucket. An implicit +Inf bucket is added.
29    ///
30    /// `shard_count` is passed to each underlying `Counter`.
31    pub fn new(bounds: &[u64], shard_count: usize) -> Self {
32        let buckets = (0..=bounds.len())
33            .map(|_| Counter::new(shard_count))
34            .collect();
35
36        Self {
37            bounds: bounds.to_vec(),
38            buckets,
39            sum: Counter::new(shard_count),
40            count: Counter::new(shard_count),
41        }
42    }
43
44    /// Create a histogram with default latency buckets (in microseconds).
45    ///
46    /// Buckets: 10µs, 50µs, 100µs, 500µs, 1ms, 5ms, 10ms, 50ms, 100ms, 500ms, 1s, 5s, 10s
47    pub fn with_latency_buckets(shard_count: usize) -> Self {
48        Self::new(
49            &[
50                10,         // 10µs
51                50,         // 50µs
52                100,        // 100µs
53                500,        // 500µs
54                1_000,      // 1ms
55                5_000,      // 5ms
56                10_000,     // 10ms
57                50_000,     // 50ms
58                100_000,    // 100ms
59                500_000,    // 500ms
60                1_000_000,  // 1s
61                5_000_000,  // 5s
62                10_000_000, // 10s
63            ],
64            shard_count,
65        )
66    }
67
68    /// Record a value in the histogram.
69    #[inline]
70    pub fn record(&self, value: u64) {
71        // Find bucket via linear search (should be fast for small bucket counts)
72        let bucket_idx = self
73            .bounds
74            .iter()
75            .position(|&bound| value <= bound)
76            .unwrap_or(self.bounds.len());
77
78        self.buckets[bucket_idx].inc();
79        self.sum.add(value as isize);
80        self.count.inc();
81    }
82
83    /// Get cumulative bucket counts -- for Prometheus exposition.
84    ///
85    /// Returns pairs of (upper_bound, cumulative_count). The last entry
86    /// has bound `u64::MAX` representing +Inf.
87    pub fn buckets_cumulative(&self) -> Vec<(u64, u64)> {
88        let mut cumulative = 0i64;
89        let mut result = Vec::with_capacity(self.buckets.len());
90
91        for (i, counter) in self.buckets.iter().enumerate() {
92            cumulative += counter.sum() as i64;
93            let bound = if i < self.bounds.len() {
94                self.bounds[i]
95            } else {
96                u64::MAX // +Inf
97            };
98            result.push((bound, cumulative as u64));
99        }
100
101        result
102    }
103
104    pub fn sum(&self) -> u64 {
105        self.sum.sum() as u64
106    }
107
108    pub fn count(&self) -> u64 {
109        self.count.sum() as u64
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_basic_recording() {
119        let h = Histogram::new(&[10, 100, 1000], 4);
120
121        h.record(5); // bucket 0 (≤10)
122        h.record(50); // bucket 1 (≤100)
123        h.record(500); // bucket 2 (≤1000)
124        h.record(5000); // bucket 3 (+Inf)
125
126        let buckets = h.buckets_cumulative();
127        assert_eq!(buckets.len(), 4);
128        assert_eq!(buckets[0], (10, 1)); // ≤10: 1 cumulative
129        assert_eq!(buckets[1], (100, 2)); // ≤100: 2 cumulative
130        assert_eq!(buckets[2], (1000, 3)); // ≤1000: 3 cumulative
131        assert_eq!(buckets[3], (u64::MAX, 4)); // +Inf: 4 cumulative
132
133        assert_eq!(h.count(), 4);
134        assert_eq!(h.sum(), 5 + 50 + 500 + 5000);
135    }
136
137    #[test]
138    fn test_boundary_values() {
139        let h = Histogram::new(&[10, 100], 4);
140
141        h.record(10); // exactly on boundary, goes in bucket 0
142        h.record(100); // exactly on boundary, goes in bucket 1
143
144        let buckets = h.buckets_cumulative();
145        assert_eq!(buckets[0], (10, 1));
146        assert_eq!(buckets[1], (100, 2));
147    }
148
149    #[test]
150    fn test_latency_buckets() {
151        let h = Histogram::with_latency_buckets(4);
152
153        h.record(5); // 5µs
154        h.record(1_000); // 1ms
155        h.record(1_000_000); // 1s
156
157        assert_eq!(h.count(), 3);
158    }
159}