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, buckets, sum, count) in histogram.iter() {
42///     println!("{}={}: count={}", Endpoint::LABEL_NAME, label.variant_name(), 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, cumulative_buckets, sum, count) for each variant.
102    pub fn iter(&self) -> impl Iterator<Item = (L, Vec<(u64, u64)>, u64, u64)> + '_ {
103        self.histograms.iter().enumerate().map(|(idx, histogram)| {
104            (
105                L::from_index(idx),
106                histogram.buckets_cumulative(),
107                histogram.sum(),
108                histogram.count(),
109            )
110        })
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[derive(Copy, Clone, Debug, PartialEq)]
119    enum TestLabel {
120        Fast,
121        Medium,
122        Slow,
123    }
124
125    impl LabelEnum for TestLabel {
126        const CARDINALITY: usize = 3;
127        const LABEL_NAME: &'static str = "speed";
128
129        fn as_index(self) -> usize {
130            self as usize
131        }
132
133        fn from_index(index: usize) -> Self {
134            match index {
135                0 => Self::Fast,
136                1 => Self::Medium,
137                _ => Self::Slow,
138            }
139        }
140
141        fn variant_name(self) -> &'static str {
142            match self {
143                Self::Fast => "fast",
144                Self::Medium => "medium",
145                Self::Slow => "slow",
146            }
147        }
148    }
149
150    #[test]
151    fn test_basic_recording() {
152        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::new(&[10, 100, 1000], 4);
153
154        histogram.record(TestLabel::Fast, 5);
155        histogram.record(TestLabel::Fast, 8);
156        histogram.record(TestLabel::Medium, 50);
157        histogram.record(TestLabel::Slow, 5000);
158
159        assert_eq!(histogram.get(TestLabel::Fast).count(), 2);
160        assert_eq!(histogram.get(TestLabel::Medium).count(), 1);
161        assert_eq!(histogram.get(TestLabel::Slow).count(), 1);
162    }
163
164    #[test]
165    fn test_iteration() {
166        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::new(&[100], 4);
167
168        histogram.record(TestLabel::Fast, 50);
169        histogram.record(TestLabel::Medium, 150);
170
171        let data: Vec<_> = histogram.iter().collect();
172        assert_eq!(data.len(), 3);
173
174        // Fast: 1 value in bucket 0
175        assert_eq!(data[0].0, TestLabel::Fast);
176        assert_eq!(data[0].3, 1); // count
177
178        // Medium: 1 value in +Inf bucket
179        assert_eq!(data[1].0, TestLabel::Medium);
180        assert_eq!(data[1].3, 1); // count
181
182        // Slow: no values
183        assert_eq!(data[2].0, TestLabel::Slow);
184        assert_eq!(data[2].3, 0); // count
185    }
186
187    #[test]
188    fn test_latency_buckets() {
189        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::with_latency_buckets(4);
190
191        histogram.record(TestLabel::Fast, 5); // 5µs
192        histogram.record(TestLabel::Fast, 100); // 100µs
193        histogram.record(TestLabel::Medium, 1_000); // 1ms
194
195        assert_eq!(histogram.get(TestLabel::Fast).count(), 2);
196        assert_eq!(histogram.get(TestLabel::Medium).count(), 1);
197    }
198
199    #[test]
200    fn test_concurrent_recording() {
201        let histogram: LabeledHistogram<TestLabel> = LabeledHistogram::new(&[100], 4);
202
203        std::thread::scope(|s| {
204            for _ in 0..4 {
205                s.spawn(|| {
206                    for i in 0..1000 {
207                        histogram.record(TestLabel::Fast, i);
208                    }
209                });
210            }
211        });
212
213        assert_eq!(histogram.get(TestLabel::Fast).count(), 4000);
214    }
215}