Skip to main content

rust_serv/metrics/
histogram.rs

1//! Histogram metric - distribution of values
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::RwLock;
5
6/// Histogram bucket configuration
7#[derive(Debug, Clone)]
8pub struct BucketConfig {
9    /// Bucket boundaries (upper bounds)
10    pub boundaries: Vec<f64>,
11}
12
13impl Default for BucketConfig {
14    fn default() -> Self {
15        // Default Prometheus-style buckets for response times in seconds
16        Self {
17            boundaries: vec![
18                0.001,  // 1ms
19                0.005,  // 5ms
20                0.01,   // 10ms
21                0.025,  // 25ms
22                0.05,   // 50ms
23                0.1,    // 100ms
24                0.25,   // 250ms
25                0.5,    // 500ms
26                1.0,    // 1s
27                2.5,    // 2.5s
28                5.0,    // 5s
29                10.0,   // 10s
30            ],
31        }
32    }
33}
34
35impl BucketConfig {
36    /// Create custom bucket configuration
37    pub fn new(boundaries: Vec<f64>) -> Self {
38        Self { boundaries }
39    }
40}
41
42/// A histogram samples observations and counts them in configurable buckets
43#[derive(Debug)]
44pub struct Histogram {
45    /// Bucket counts
46    buckets: Vec<AtomicU64>,
47    /// Bucket boundaries
48    boundaries: Vec<f64>,
49    /// Sum of all observed values
50    sum: RwLock<f64>,
51    /// Count of observations
52    count: AtomicU64,
53    /// Metric name
54    name: String,
55    /// Help text
56    help: String,
57}
58
59impl Histogram {
60    /// Create a new histogram with default buckets
61    pub fn new(name: impl Into<String>, help: impl Into<String>) -> Self {
62        Self::with_buckets(name, help, BucketConfig::default())
63    }
64
65    /// Create a new histogram with custom buckets
66    pub fn with_buckets(
67        name: impl Into<String>,
68        help: impl Into<String>,
69        config: BucketConfig,
70    ) -> Self {
71        let num_buckets = config.boundaries.len() + 1; // +1 for +Inf bucket
72        Self {
73            buckets: (0..num_buckets).map(|_| AtomicU64::new(0)).collect(),
74            boundaries: config.boundaries,
75            sum: RwLock::new(0.0),
76            count: AtomicU64::new(0),
77            name: name.into(),
78            help: help.into(),
79        }
80    }
81
82    /// Observe a value
83    pub fn observe(&self, value: f64) {
84        // Find the appropriate bucket
85        let bucket_idx = self.find_bucket(value);
86        self.buckets[bucket_idx].fetch_add(1, Ordering::Relaxed);
87        
88        // Update sum and count
89        self.count.fetch_add(1, Ordering::Relaxed);
90        if let Ok(mut sum) = self.sum.write() {
91            *sum += value;
92        }
93    }
94
95    /// Find the bucket index for a value
96    fn find_bucket(&self, value: f64) -> usize {
97        for (idx, &boundary) in self.boundaries.iter().enumerate() {
98            if value <= boundary {
99                return idx;
100            }
101        }
102        // Value exceeds all boundaries, goes to +Inf bucket
103        self.boundaries.len()
104    }
105
106    /// Get bucket counts
107    pub fn bucket_counts(&self) -> Vec<u64> {
108        self.buckets.iter().map(|b| b.load(Ordering::Relaxed)).collect()
109    }
110
111    /// Get bucket boundaries
112    pub fn boundaries(&self) -> &[f64] {
113        &self.boundaries
114    }
115
116    /// Get sum of all values
117    pub fn sum(&self) -> f64 {
118        self.sum.read().map(|s| *s).unwrap_or(0.0)
119    }
120
121    /// Get count of observations
122    pub fn count(&self) -> u64 {
123        self.count.load(Ordering::Relaxed)
124    }
125
126    /// Get histogram name
127    pub fn name(&self) -> &str {
128        &self.name
129    }
130
131    /// Get help text
132    pub fn help(&self) -> &str {
133        &self.help
134    }
135
136    /// Calculate percentile (approximate)
137    pub fn percentile(&self, p: f64) -> Option<f64> {
138        if p < 0.0 || p > 100.0 {
139            return None;
140        }
141
142        let total = self.count();
143        if total == 0 {
144            return None;
145        }
146
147        let target = (p / 100.0) * total as f64;
148        let mut cumulative = 0u64;
149
150        for (idx, count) in self.bucket_counts().iter().enumerate() {
151            cumulative += *count;
152            if cumulative as f64 >= target {
153                if idx < self.boundaries.len() {
154                    return Some(self.boundaries[idx]);
155                } else {
156                    // +Inf bucket
157                    return Some(f64::INFINITY);
158                }
159            }
160        }
161
162        None
163    }
164
165    /// Reset the histogram
166    pub fn reset(&self) {
167        for bucket in &self.buckets {
168            bucket.store(0, Ordering::Relaxed);
169        }
170        self.count.store(0, Ordering::Relaxed);
171        if let Ok(mut sum) = self.sum.write() {
172            *sum = 0.0;
173        }
174    }
175}
176
177impl Clone for Histogram {
178    fn clone(&self) -> Self {
179        Self {
180            buckets: self.buckets.iter().map(|b| AtomicU64::new(b.load(Ordering::Relaxed))).collect(),
181            boundaries: self.boundaries.clone(),
182            sum: RwLock::new(*self.sum.read().unwrap()),
183            count: AtomicU64::new(self.count.load(Ordering::Relaxed)),
184            name: self.name.clone(),
185            help: self.help.clone(),
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::f64::EPSILON;
194
195    #[test]
196    fn test_histogram_creation() {
197        let hist = Histogram::new("request_duration", "Request duration in seconds");
198        assert_eq!(hist.count(), 0);
199        assert_eq!(hist.sum(), 0.0);
200        assert_eq!(hist.name(), "request_duration");
201        assert!(!hist.boundaries().is_empty());
202    }
203
204    #[test]
205    fn test_histogram_observe() {
206        let hist = Histogram::new("test", "test histogram");
207        hist.observe(0.1);
208        hist.observe(0.2);
209        
210        assert_eq!(hist.count(), 2);
211        assert!((hist.sum() - 0.3).abs() < EPSILON);
212    }
213
214    #[test]
215    fn test_histogram_bucket_distribution() {
216        let config = BucketConfig::new(vec![0.1, 0.5, 1.0]);
217        let hist = Histogram::with_buckets("test", "test", config);
218        
219        // Observe values in different buckets
220        hist.observe(0.05);  // <= 0.1
221        hist.observe(0.3);   // <= 0.5
222        hist.observe(0.7);   // <= 1.0
223        hist.observe(2.0);   // > 1.0 (+Inf)
224        
225        let counts = hist.bucket_counts();
226        assert_eq!(counts[0], 1); // <= 0.1
227        assert_eq!(counts[1], 1); // <= 0.5
228        assert_eq!(counts[2], 1); // <= 1.0
229        assert_eq!(counts[3], 1); // +Inf
230    }
231
232    #[test]
233    fn test_histogram_percentile() {
234        let config = BucketConfig::new(vec![0.1, 0.5, 1.0]);
235        let hist = Histogram::with_buckets("test", "test", config);
236        
237        // Add some values
238        for _ in 0..10 {
239            hist.observe(0.05);
240        }
241        for _ in 0..10 {
242            hist.observe(0.3);
243        }
244        for _ in 0..10 {
245            hist.observe(0.8);
246        }
247        
248        // P50 should be around 0.3
249        let p50 = hist.percentile(50.0).unwrap();
250        assert!((p50 - 0.3).abs() < EPSILON || (p50 - 0.5).abs() < EPSILON);
251        
252        // P99 should be <= 1.0
253        let p99 = hist.percentile(99.0).unwrap();
254        assert!(p99 <= 1.0 || p99.is_infinite());
255    }
256
257    #[test]
258    fn test_histogram_percentile_empty() {
259        let hist = Histogram::new("test", "test");
260        assert!(hist.percentile(50.0).is_none());
261    }
262
263    #[test]
264    fn test_histogram_percentile_invalid() {
265        let hist = Histogram::new("test", "test");
266        hist.observe(0.1);
267        
268        assert!(hist.percentile(-1.0).is_none());
269        assert!(hist.percentile(101.0).is_none());
270    }
271
272    #[test]
273    fn test_histogram_reset() {
274        let hist = Histogram::new("test", "test");
275        hist.observe(0.1);
276        hist.observe(0.2);
277        
278        assert_eq!(hist.count(), 2);
279        
280        hist.reset();
281        
282        assert_eq!(hist.count(), 0);
283        assert_eq!(hist.sum(), 0.0);
284        assert!(hist.bucket_counts().iter().all(|&c| c == 0));
285    }
286
287    #[test]
288    fn test_histogram_clone() {
289        let hist = Histogram::new("test", "test");
290        hist.observe(0.5);
291        
292        let cloned = hist.clone();
293        assert_eq!(cloned.count(), 1);
294        assert!((cloned.sum() - 0.5).abs() < EPSILON);
295        assert_eq!(cloned.name(), "test");
296    }
297
298    #[test]
299    fn test_histogram_concurrent_observe() {
300        use std::sync::Arc;
301        use std::thread;
302
303        let hist = Arc::new(Histogram::new("test", "test"));
304        let mut handles = vec![];
305
306        for _ in 0..10 {
307            let hist_clone = Arc::clone(&hist);
308            handles.push(thread::spawn(move || {
309                hist_clone.observe(0.1);
310            }));
311        }
312
313        for handle in handles {
314            handle.join().unwrap();
315        }
316
317        assert_eq!(hist.count(), 10);
318        assert!((hist.sum() - 1.0).abs() < EPSILON);
319    }
320
321    #[test]
322    fn test_custom_bucket_config() {
323        let config = BucketConfig::new(vec![0.01, 0.1, 1.0]);
324        let hist = Histogram::with_buckets("test", "test", config);
325        
326        assert_eq!(hist.boundaries().len(), 3);
327        assert_eq!(hist.bucket_counts().len(), 4); // 3 boundaries + +Inf
328    }
329
330    #[test]
331    fn test_histogram_large_values() {
332        let hist = Histogram::new("test", "test");
333        hist.observe(1000.0);
334        hist.observe(10000.0);
335        
336        assert_eq!(hist.count(), 2);
337        assert!((hist.sum() - 11000.0).abs() < EPSILON);
338    }
339
340    #[test]
341    fn test_histogram_zero_value() {
342        let hist = Histogram::new("test", "test");
343        hist.observe(0.0);
344        
345        assert_eq!(hist.count(), 1);
346        assert_eq!(hist.sum(), 0.0);
347    }
348}