Skip to main content

fast_telemetry/metric/
labeled_counter.rs

1//! Enum-indexed counter for dimensional metrics with O(1) lookup.
2
3use crate::Counter;
4use crate::label::LabelEnum;
5use std::marker::PhantomData;
6
7/// A counter indexed by an enum label, providing O(1) dimensional metrics.
8///
9/// Instead of using a HashMap to look up counters by label values, this type
10/// uses the enum variant's index to directly access an array of counters.
11/// This eliminates hashing overhead on the hot path.
12///
13/// # Example
14///
15/// ```ignore
16/// use fast_telemetry::{LabeledCounter, LabelEnum};
17///
18/// #[derive(Copy, Clone, Debug)]
19/// enum HttpMethod { Get, Post, Put, Delete, Other }
20///
21/// impl LabelEnum for HttpMethod {
22///     const CARDINALITY: usize = 5;
23///     const LABEL_NAME: &'static str = "method";
24///
25///     fn as_index(self) -> usize { self as usize }
26///     fn from_index(index: usize) -> Self {
27///         match index {
28///             0 => Self::Get, 1 => Self::Post, 2 => Self::Put,
29///             3 => Self::Delete, _ => Self::Other,
30///         }
31///     }
32///     fn variant_name(self) -> &'static str {
33///         match self {
34///             Self::Get => "get", Self::Post => "post", Self::Put => "put",
35///             Self::Delete => "delete", Self::Other => "other",
36///         }
37///     }
38/// }
39///
40/// let counter: LabeledCounter<HttpMethod> = LabeledCounter::new(4);
41/// counter.inc(HttpMethod::Get);
42/// counter.add(HttpMethod::Post, 5);
43///
44/// // Iteration for export
45/// for (label, count) in counter.iter() {
46///     println!("{}={}: {}", HttpMethod::LABEL_NAME, label.variant_name(), count);
47/// }
48/// ```
49pub struct LabeledCounter<L: LabelEnum> {
50    counters: Vec<Counter>,
51    _phantom: PhantomData<L>,
52}
53
54impl<L: LabelEnum> LabeledCounter<L> {
55    /// Create a new labeled counter.
56    ///
57    /// `shard_count` is passed to each underlying `Counter` for thread-sharding.
58    pub fn new(shard_count: usize) -> Self {
59        let counters = (0..L::CARDINALITY)
60            .map(|_| Counter::new(shard_count))
61            .collect();
62        Self {
63            counters,
64            _phantom: PhantomData,
65        }
66    }
67
68    /// Increment the counter for the given label by 1.
69    #[inline]
70    pub fn inc(&self, label: L) {
71        self.add(label, 1);
72    }
73
74    /// Add a value to the counter for the given label.
75    #[inline]
76    pub fn add(&self, label: L, value: isize) {
77        let idx = label.as_index();
78        debug_assert!(idx < self.counters.len(), "label index out of bounds");
79        // SAFETY: LabelEnum::CARDINALITY guarantees idx < counters.len()
80        if cfg!(debug_assertions) {
81            self.counters[idx].add(value);
82        } else {
83            unsafe { self.counters.get_unchecked(idx) }.add(value);
84        }
85    }
86
87    /// Get the current count for a specific label.
88    #[inline]
89    pub fn get(&self, label: L) -> isize {
90        let idx = label.as_index();
91        if cfg!(debug_assertions) {
92            self.counters[idx].sum()
93        } else {
94            unsafe { self.counters.get_unchecked(idx) }.sum()
95        }
96    }
97
98    /// Get the sum across all labels.
99    pub fn sum_all(&self) -> isize {
100        self.counters.iter().map(|c| c.sum()).sum()
101    }
102
103    /// Iterate over all label/count pairs.
104    ///
105    /// Used for Prometheus export.
106    pub fn iter(&self) -> impl Iterator<Item = (L, isize)> + '_ {
107        self.counters
108            .iter()
109            .enumerate()
110            .map(|(idx, counter)| (L::from_index(idx), counter.sum()))
111    }
112
113    /// Swap all counters and return label/count pairs.
114    ///
115    /// Useful for delta-style metrics export.
116    pub fn swap_all(&self) -> impl Iterator<Item = (L, isize)> + '_ {
117        self.counters
118            .iter()
119            .enumerate()
120            .map(|(idx, counter)| (L::from_index(idx), counter.swap()))
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[derive(Copy, Clone, Debug, PartialEq)]
129    enum TestLabel {
130        A,
131        B,
132        C,
133    }
134
135    impl LabelEnum for TestLabel {
136        const CARDINALITY: usize = 3;
137        const LABEL_NAME: &'static str = "test";
138
139        fn as_index(self) -> usize {
140            self as usize
141        }
142
143        fn from_index(index: usize) -> Self {
144            match index {
145                0 => Self::A,
146                1 => Self::B,
147                _ => Self::C,
148            }
149        }
150
151        fn variant_name(self) -> &'static str {
152            match self {
153                Self::A => "a",
154                Self::B => "b",
155                Self::C => "c",
156            }
157        }
158    }
159
160    #[test]
161    fn test_basic_operations() {
162        let counter: LabeledCounter<TestLabel> = LabeledCounter::new(4);
163
164        counter.inc(TestLabel::A);
165        counter.add(TestLabel::B, 5);
166        counter.inc(TestLabel::A);
167
168        assert_eq!(counter.get(TestLabel::A), 2);
169        assert_eq!(counter.get(TestLabel::B), 5);
170        assert_eq!(counter.get(TestLabel::C), 0);
171    }
172
173    #[test]
174    fn test_sum_all() {
175        let counter: LabeledCounter<TestLabel> = LabeledCounter::new(4);
176
177        counter.add(TestLabel::A, 10);
178        counter.add(TestLabel::B, 20);
179        counter.add(TestLabel::C, 30);
180
181        assert_eq!(counter.sum_all(), 60);
182    }
183
184    #[test]
185    fn test_iteration() {
186        let counter: LabeledCounter<TestLabel> = LabeledCounter::new(4);
187
188        counter.add(TestLabel::A, 1);
189        counter.add(TestLabel::B, 2);
190        counter.add(TestLabel::C, 3);
191
192        let pairs: Vec<_> = counter.iter().collect();
193        assert_eq!(pairs.len(), 3);
194        assert_eq!(pairs[0], (TestLabel::A, 1));
195        assert_eq!(pairs[1], (TestLabel::B, 2));
196        assert_eq!(pairs[2], (TestLabel::C, 3));
197    }
198
199    #[test]
200    fn test_swap_all() {
201        let counter: LabeledCounter<TestLabel> = LabeledCounter::new(4);
202
203        counter.add(TestLabel::A, 10);
204        counter.add(TestLabel::B, 20);
205
206        let swapped: Vec<_> = counter.swap_all().collect();
207        assert_eq!(swapped[0], (TestLabel::A, 10));
208        assert_eq!(swapped[1], (TestLabel::B, 20));
209
210        // After swap, counters should be zero
211        assert_eq!(counter.get(TestLabel::A), 0);
212        assert_eq!(counter.get(TestLabel::B), 0);
213    }
214
215    #[test]
216    fn test_concurrent_access() {
217        let counter: LabeledCounter<TestLabel> = LabeledCounter::new(4);
218
219        std::thread::scope(|s| {
220            for _ in 0..4 {
221                s.spawn(|| {
222                    for _ in 0..1000 {
223                        counter.inc(TestLabel::A);
224                        counter.inc(TestLabel::B);
225                    }
226                });
227            }
228        });
229
230        assert_eq!(counter.get(TestLabel::A), 4000);
231        assert_eq!(counter.get(TestLabel::B), 4000);
232        assert_eq!(counter.get(TestLabel::C), 0);
233    }
234}