Skip to main content

fast_telemetry/metric/
labeled_histogram.rs

1//! Enum-indexed histogram for dimensional latency/distribution metrics.
2
3use crate::Histogram;
4use crate::label::LabelEnum;
5use std::marker::PhantomData;
6
7/// A histogram indexed by an enum label, providing O(1) dimensional metrics.
8///
9/// Each label variant has its own histogram with independent buckets.
10///
11/// # Example
12///
13/// ```ignore
14/// use fast_telemetry::{LabeledHistogram, LabelEnum};
15///
16/// #[derive(Copy, Clone, Debug)]
17/// enum Endpoint { Api, Auth, Static }
18///
19/// impl LabelEnum for Endpoint {
20///     const CARDINALITY: usize = 3;
21///     const LABEL_NAME: &'static str = "endpoint";
22///
23///     fn as_index(self) -> usize { self as usize }
24///     fn from_index(index: usize) -> Self {
25///         match index {
26///             0 => Self::Api, 1 => Self::Auth, _ => Self::Static,
27///         }
28///     }
29///     fn variant_name(self) -> &'static str {
30///         match self {
31///             Self::Api => "api", Self::Auth => "auth", Self::Static => "static",
32///         }
33///     }
34/// }
35///
36/// let histogram: LabeledHistogram<Endpoint> = LabeledHistogram::with_latency_buckets(4);
37/// histogram.record(Endpoint::Api, 150);    // 150µs
38/// histogram.record(Endpoint::Auth, 2000);  // 2ms
39///
40/// // Iteration for export
41/// for (label, histogram) in histogram.iter() {
42///     println!("{}={}: count={}", Endpoint::LABEL_NAME, label.variant_name(), histogram.count());
43/// }
44/// ```
45pub struct LabeledHistogram<L: LabelEnum> {
46    histograms: Vec<Histogram>,
47    _phantom: PhantomData<L>,
48}
49
50impl<L: LabelEnum> LabeledHistogram<L> {
51    /// Create a labeled histogram with custom bucket boundaries.
52    ///
53    /// Each label variant gets its own histogram with these boundaries.
54    pub fn new(bounds: &[u64], shard_count: usize) -> Self {
55        let histograms = (0..L::CARDINALITY)
56            .map(|_| Histogram::new(bounds, shard_count))
57            .collect();
58        Self {
59            histograms,
60            _phantom: PhantomData,
61        }
62    }
63
64    /// Create a labeled histogram with default latency buckets (microseconds).
65    ///
66    /// Buckets: 10µs, 50µs, 100µs, 500µs, 1ms, 5ms, 10ms, 50ms, 100ms, 500ms, 1s, 5s, 10s
67    pub fn with_latency_buckets(shard_count: usize) -> Self {
68        let histograms = (0..L::CARDINALITY)
69            .map(|_| Histogram::with_latency_buckets(shard_count))
70            .collect();
71        Self {
72            histograms,
73            _phantom: PhantomData,
74        }
75    }
76
77    /// Record a value in the histogram for the given label.
78    #[inline]
79    pub fn record(&self, label: L, value: u64) {
80        let idx = label.as_index();
81        debug_assert!(idx < self.histograms.len(), "label index out of bounds");
82        if cfg!(debug_assertions) {
83            self.histograms[idx].record(value);
84        } else {
85            unsafe { self.histograms.get_unchecked(idx) }.record(value);
86        }
87    }
88
89    /// Get the histogram for a specific label (for detailed inspection).
90    pub fn get(&self, label: L) -> &Histogram {
91        let idx = label.as_index();
92        if cfg!(debug_assertions) {
93            &self.histograms[idx]
94        } else {
95            unsafe { self.histograms.get_unchecked(idx) }
96        }
97    }
98
99    /// Iterate over all label/histogram data for export.
100    ///
101    /// Returns (label, &Histogram) for each variant. Callers that need
102    /// cumulative buckets can call `histogram.buckets_cumulative_iter()`
103    /// without allocating a Vec; callers that only need sum/count (e.g.
104    /// DogStatsD) skip the bucket walk entirely.
105    pub fn iter(&self) -> impl Iterator<Item = (L, &'_ Histogram)> + '_ {
106        self.histograms
107            .iter()
108            .enumerate()
109            .map(|(idx, histogram)| (L::from_index(idx), histogram))
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[derive(Copy, Clone, Debug, PartialEq)]
118    enum TestLabel {
119        Fast,
120        Medium,
121        Slow,
122    }
123
124    impl LabelEnum for TestLabel {
125        const CARDINALITY: usize = 3;
126        const LABEL_NAME: &'static str = "speed";
127
128        fn as_index(self) -> usize {
129            self as usize
130        }
131
132        fn from_index(index: usize) -> Self {
133            match index {
134                0 => Self::Fast,
135                1 => Self::Medium,
136                _ => Self::Slow,
137            }
138        }
139
140        fn variant_name(self) -> &'static str {
141            match self {
142                Self::Fast => "fast",
143                Self::Medium => "medium",
144                Self::Slow => "slow",
145            }
146        }
147    }
148
149    #[test]
150    fn test_basic_recording() {
151        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::new(&[10, 100, 1000], 4);
152
153        histogram.record(TestLabel::Fast, 5);
154        histogram.record(TestLabel::Fast, 8);
155        histogram.record(TestLabel::Medium, 50);
156        histogram.record(TestLabel::Slow, 5000);
157
158        assert_eq!(histogram.get(TestLabel::Fast).count(), 2);
159        assert_eq!(histogram.get(TestLabel::Medium).count(), 1);
160        assert_eq!(histogram.get(TestLabel::Slow).count(), 1);
161    }
162
163    #[test]
164    fn test_iteration() {
165        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::new(&[100], 4);
166
167        histogram.record(TestLabel::Fast, 50);
168        histogram.record(TestLabel::Medium, 150);
169
170        let data: Vec<_> = histogram.iter().collect();
171        assert_eq!(data.len(), 3);
172
173        // Fast: 1 value in bucket 0
174        assert_eq!(data[0].0, TestLabel::Fast);
175        assert_eq!(data[0].1.count(), 1);
176
177        // Medium: 1 value in +Inf bucket
178        assert_eq!(data[1].0, TestLabel::Medium);
179        assert_eq!(data[1].1.count(), 1);
180
181        // Slow: no values
182        assert_eq!(data[2].0, TestLabel::Slow);
183        assert_eq!(data[2].1.count(), 0);
184    }
185
186    #[test]
187    fn test_latency_buckets() {
188        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::with_latency_buckets(4);
189
190        histogram.record(TestLabel::Fast, 5); // 5µs
191        histogram.record(TestLabel::Fast, 100); // 100µs
192        histogram.record(TestLabel::Medium, 1_000); // 1ms
193
194        assert_eq!(histogram.get(TestLabel::Fast).count(), 2);
195        assert_eq!(histogram.get(TestLabel::Medium).count(), 1);
196    }
197
198    #[test]
199    fn test_concurrent_recording() {
200        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::new(&[100], 4);
201
202        std::thread::scope(|s| {
203            for _ in 0..4 {
204                s.spawn(|| {
205                    for i in 0..1000 {
206                        histogram.record(TestLabel::Fast, i);
207                    }
208                });
209            }
210        });
211
212        assert_eq!(histogram.get(TestLabel::Fast).count(), 4000);
213    }
214}