Skip to main content

scirs2_core/structured_logging/
metrics.rs

1//! OpenTelemetry-compatible metrics: counters, gauges, histograms, and a
2//! registry for named, labelled instruments.
3//!
4//! All types are thread-safe (backed by atomics or `Mutex`) and can be
5//! shared across threads via `Arc`.
6
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10
11// ============================================================================
12// Counter
13// ============================================================================
14
15/// A monotonically-increasing u64 counter.
16///
17/// Backed by an atomic so `add` is lock-free.
18pub struct Counter {
19    value: AtomicU64,
20    name: String,
21    labels: Vec<(String, String)>,
22}
23
24impl Counter {
25    /// Create a counter with the given name and labels.
26    pub fn new(name: impl Into<String>, labels: Vec<(String, String)>) -> Self {
27        Self {
28            value: AtomicU64::new(0),
29            name: name.into(),
30            labels,
31        }
32    }
33
34    /// Add `value` to the counter.
35    pub fn add(&self, value: u64) {
36        self.value.fetch_add(value, Ordering::Relaxed);
37    }
38
39    /// Increment by 1 (convenience wrapper around `add`).
40    pub fn inc(&self) {
41        self.add(1);
42    }
43
44    /// Return the current value.
45    pub fn get(&self) -> u64 {
46        self.value.load(Ordering::Relaxed)
47    }
48
49    /// Return the metric name.
50    pub fn name(&self) -> &str {
51        &self.name
52    }
53
54    /// Return the label set.
55    pub fn labels(&self) -> &[(String, String)] {
56        &self.labels
57    }
58}
59
60impl std::fmt::Debug for Counter {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("Counter")
63            .field("name", &self.name)
64            .field("value", &self.get())
65            .finish()
66    }
67}
68
69// ============================================================================
70// Gauge
71// ============================================================================
72
73/// A gauge that records an instantaneous f64 measurement.
74///
75/// The value is stored as a bit-pattern inside a u64 atomic for lock-free
76/// access.
77pub struct Gauge {
78    /// Stored as the IEEE 754 bit pattern of an f64.
79    bits: AtomicU64,
80    name: String,
81}
82
83impl Gauge {
84    /// Create a gauge initialised to 0.0.
85    pub fn new(name: impl Into<String>) -> Self {
86        Self {
87            bits: AtomicU64::new(0f64.to_bits()),
88            name: name.into(),
89        }
90    }
91
92    /// Set the gauge to `v`.
93    pub fn set(&self, v: f64) {
94        self.bits.store(v.to_bits(), Ordering::Relaxed);
95    }
96
97    /// Return the current value.
98    pub fn get(&self) -> f64 {
99        f64::from_bits(self.bits.load(Ordering::Relaxed))
100    }
101
102    /// Return the metric name.
103    pub fn name(&self) -> &str {
104        &self.name
105    }
106}
107
108impl std::fmt::Debug for Gauge {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("Gauge")
111            .field("name", &self.name)
112            .field("value", &self.get())
113            .finish()
114    }
115}
116
117// ============================================================================
118// Histogram
119// ============================================================================
120
121/// A histogram that distributes observations into configurable buckets.
122///
123/// Follows the Prometheus/OTel convention: each bucket counts observations
124/// whose value is **≤ upper_bound** (i.e. cumulative).
125///
126/// A `+Inf` bucket is always added implicitly.
127pub struct Histogram {
128    /// Upper bounds of each bucket (sorted ascending, without +Inf).
129    boundaries: Vec<f64>,
130    /// Cumulative counts per bucket (`boundaries.len() + 1` entries, last =
131    /// +Inf bucket).
132    counts: Mutex<Vec<u64>>,
133    /// Running sum of all recorded values.
134    sum: Mutex<f64>,
135    /// Total number of observations.
136    count: AtomicU64,
137    name: String,
138    /// Raw samples retained for percentile computation (capped at 10 000).
139    samples: Mutex<Vec<f64>>,
140}
141
142impl Histogram {
143    /// Create a histogram with the given name and bucket boundaries.
144    ///
145    /// `boundaries` are sorted internally; duplicates are removed.
146    pub fn new(name: impl Into<String>, boundaries: &[f64]) -> Self {
147        let mut bounds: Vec<f64> = boundaries.to_vec();
148        bounds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
149        bounds.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON);
150        let n = bounds.len() + 1; // +1 for +Inf bucket
151        Self {
152            boundaries: bounds,
153            counts: Mutex::new(vec![0u64; n]),
154            sum: Mutex::new(0.0),
155            count: AtomicU64::new(0),
156            name: name.into(),
157            samples: Mutex::new(Vec::with_capacity(1024)),
158        }
159    }
160
161    /// Record a single observation.
162    pub fn record(&self, value: f64) {
163        // Update sum.
164        if let Ok(mut s) = self.sum.lock() {
165            *s += value;
166        }
167        // Increment count.
168        self.count.fetch_add(1, Ordering::Relaxed);
169
170        // Find the correct bucket.
171        if let Ok(mut counts) = self.counts.lock() {
172            let mut placed = false;
173            for (i, &bound) in self.boundaries.iter().enumerate() {
174                if value <= bound {
175                    // Increment this bucket and all subsequent (cumulative).
176                    for j in i..counts.len() {
177                        counts[j] += 1;
178                    }
179                    placed = true;
180                    break;
181                }
182            }
183            if !placed {
184                // Falls into +Inf bucket only.
185                if let Some(last) = counts.last_mut() {
186                    *last += 1;
187                }
188            }
189        }
190
191        // Retain raw samples for percentile computation (cap at 10 000).
192        if let Ok(mut s) = self.samples.lock() {
193            if s.len() < 10_000 {
194                s.push(value);
195            }
196        }
197    }
198
199    /// Return `(upper_bound, cumulative_count)` pairs for each bucket.
200    ///
201    /// The last bucket always has `f64::INFINITY` as the upper bound.
202    pub fn buckets(&self) -> Vec<(f64, u64)> {
203        let counts = self.counts.lock().map(|g| g.clone()).unwrap_or_default();
204        let mut result = Vec::with_capacity(counts.len());
205        for (i, count) in counts.iter().enumerate() {
206            let bound = self.boundaries.get(i).copied().unwrap_or(f64::INFINITY);
207            result.push((bound, *count));
208        }
209        result
210    }
211
212    /// Total number of observations recorded.
213    pub fn count(&self) -> u64 {
214        self.count.load(Ordering::Relaxed)
215    }
216
217    /// Sum of all recorded values.
218    pub fn sum(&self) -> f64 {
219        self.sum.lock().map(|g| *g).unwrap_or(0.0)
220    }
221
222    /// Return the metric name.
223    pub fn name(&self) -> &str {
224        &self.name
225    }
226
227    // ---- Percentile helpers ----
228
229    /// Compute an approximate percentile from retained samples.
230    ///
231    /// `p` should be in [0.0, 100.0].  Returns `0.0` when no samples have
232    /// been recorded.
233    pub fn percentile(&self, p: f64) -> f64 {
234        let mut samples = match self.samples.lock() {
235            Ok(g) => g.clone(),
236            Err(_) => return 0.0,
237        };
238        if samples.is_empty() {
239            return 0.0;
240        }
241        samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
242        let idx = ((p / 100.0) * (samples.len() as f64 - 1.0))
243            .round()
244            .clamp(0.0, (samples.len() - 1) as f64) as usize;
245        samples[idx]
246    }
247
248    /// 50th percentile (median).
249    pub fn p50(&self) -> f64 {
250        self.percentile(50.0)
251    }
252
253    /// 95th percentile.
254    pub fn p95(&self) -> f64 {
255        self.percentile(95.0)
256    }
257
258    /// 99th percentile.
259    pub fn p99(&self) -> f64 {
260        self.percentile(99.0)
261    }
262}
263
264impl std::fmt::Debug for Histogram {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.debug_struct("Histogram")
267            .field("name", &self.name)
268            .field("count", &self.count())
269            .field("sum", &self.sum())
270            .finish()
271    }
272}
273
274// ============================================================================
275// MeterRegistry
276// ============================================================================
277
278/// A registry for named, labelled metric instruments.
279///
280/// Returns the *same* `Arc` when the same `(name, labels)` combination is
281/// requested again.
282pub struct MeterRegistry {
283    counters: Mutex<HashMap<String, Arc<Counter>>>,
284    gauges: Mutex<HashMap<String, Arc<Gauge>>>,
285    histograms: Mutex<HashMap<String, Arc<Histogram>>>,
286}
287
288impl MeterRegistry {
289    /// Create an empty registry.
290    pub fn new() -> Self {
291        Self {
292            counters: Mutex::new(HashMap::new()),
293            gauges: Mutex::new(HashMap::new()),
294            histograms: Mutex::new(HashMap::new()),
295        }
296    }
297
298    /// Retrieve or create a `Counter` with the given name and labels.
299    pub fn counter(&self, name: &str, labels: &[(&str, &str)]) -> Arc<Counter> {
300        let key = Self::make_key(name, labels);
301        let mut map = self.counters.lock().unwrap_or_else(|e| e.into_inner());
302        map.entry(key)
303            .or_insert_with(|| {
304                Arc::new(Counter::new(
305                    name,
306                    labels
307                        .iter()
308                        .map(|(k, v)| (k.to_string(), v.to_string()))
309                        .collect(),
310                ))
311            })
312            .clone()
313    }
314
315    /// Retrieve or create a `Gauge` with the given name.
316    pub fn gauge(&self, name: &str) -> Arc<Gauge> {
317        let mut map = self.gauges.lock().unwrap_or_else(|e| e.into_inner());
318        map.entry(name.to_owned())
319            .or_insert_with(|| Arc::new(Gauge::new(name)))
320            .clone()
321    }
322
323    /// Retrieve or create a `Histogram` with the given name and bucket
324    /// boundaries.
325    ///
326    /// If the histogram already exists, the original boundaries are preserved.
327    pub fn histogram(&self, name: &str, boundaries: &[f64]) -> Arc<Histogram> {
328        let mut map = self.histograms.lock().unwrap_or_else(|e| e.into_inner());
329        map.entry(name.to_owned())
330            .or_insert_with(|| Arc::new(Histogram::new(name, boundaries)))
331            .clone()
332    }
333
334    fn make_key(name: &str, labels: &[(&str, &str)]) -> String {
335        if labels.is_empty() {
336            return name.to_owned();
337        }
338        let mut parts: Vec<String> = labels.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
339        parts.sort();
340        format!("{}{{{}}}", name, parts.join(","))
341    }
342}
343
344impl Default for MeterRegistry {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350// ============================================================================
351// Tests
352// ============================================================================
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::sync::Arc;
358    use std::thread;
359
360    #[test]
361    fn test_counter_atomic() {
362        let counter = Arc::new(Counter::new("requests", vec![]));
363        let handles: Vec<_> = (0..4)
364            .map(|_| {
365                let c = Arc::clone(&counter);
366                thread::spawn(move || {
367                    for _ in 0..250 {
368                        c.add(1);
369                    }
370                })
371            })
372            .collect();
373        for h in handles {
374            h.join().expect("thread panicked");
375        }
376        assert_eq!(counter.get(), 1000);
377    }
378
379    #[test]
380    fn test_histogram_buckets() {
381        let h = Histogram::new("latency", &[1.0, 5.0, 10.0]);
382        h.record(0.5);
383        h.record(3.0);
384        h.record(7.0);
385        h.record(20.0);
386        assert_eq!(h.count(), 4);
387
388        let buckets = h.buckets();
389        // ≤1.0: only 0.5
390        assert_eq!(buckets[0].1, 1);
391        // ≤5.0: 0.5, 3.0
392        assert_eq!(buckets[1].1, 2);
393        // ≤10.0: 0.5, 3.0, 7.0
394        assert_eq!(buckets[2].1, 3);
395        // +Inf: all 4
396        assert_eq!(buckets[3].1, 4);
397    }
398
399    #[test]
400    fn test_histogram_percentiles() {
401        let h = Histogram::new("rt", &[1.0, 10.0, 100.0]);
402        for i in 1u64..=100 {
403            h.record(i as f64);
404        }
405        let p50 = h.p50();
406        let p95 = h.p95();
407        let p99 = h.p99();
408        assert!(p50 <= p95, "p50={} p95={}", p50, p95);
409        assert!(p95 <= p99, "p95={} p99={}", p95, p99);
410    }
411
412    #[test]
413    fn test_meter_registry_same_counter() {
414        let reg = MeterRegistry::new();
415        let c1 = reg.counter("reqs", &[("method", "GET")]);
416        let c2 = reg.counter("reqs", &[("method", "GET")]);
417        c1.add(5);
418        // Both arcs point to the same counter.
419        assert_eq!(c2.get(), 5);
420    }
421
422    #[test]
423    fn test_gauge_set_get() {
424        let g = Gauge::new("cpu");
425        g.set(0.75);
426        assert!((g.get() - 0.75).abs() < 1e-9);
427    }
428}