rustrade_core/metrics.rs
1//! Pluggable metrics sink.
2//!
3//! The framework emits structured counters / gauges / histograms at
4//! every observable event. Host services plug in a [`MetricsSink`]
5//! implementation to ship them somewhere — Prometheus, StatsD, an
6//! in-process accumulator, anything.
7//!
8//! The default [`NoopSink`] discards everything.
9//!
10//! # Why a trait, not a global registry
11//!
12//! Different host services own different metrics backends. The bot is
13//! an embedded library; forcing a particular metrics layer would force
14//! every downstream consumer onto that layer. A small trait is
15//! cheap and lets the host stay in control.
16
17/// Push-based metrics interface.
18///
19/// Implementors are called from the framework's hot path on every
20/// observable event, so they must be fast and lock-light. Allocation
21/// per call is acceptable — the framework doesn't claim a fixed
22/// per-event budget — but blocking on I/O is not. Async sinks should
23/// drop events into a channel and process them on a separate task.
24///
25/// `Send + Sync + 'static` so an `Arc<dyn MetricsSink>` lives across
26/// supervised services.
27///
28/// # Example
29///
30/// A simple in-process counting sink, useful for tests and embedded
31/// dashboards.
32///
33/// ```
34/// use std::collections::HashMap;
35/// use std::sync::Mutex;
36/// use rustrade_core::MetricsSink;
37///
38/// #[derive(Default)]
39/// struct CountingSink {
40/// counters: Mutex<HashMap<String, u64>>,
41/// }
42///
43/// impl MetricsSink for CountingSink {
44/// fn counter(&self, name: &str, _labels: &[(&str, &str)], value: u64) {
45/// *self.counters.lock().unwrap().entry(name.into()).or_insert(0) += value;
46/// }
47/// fn gauge(&self, _name: &str, _labels: &[(&str, &str)], _value: f64) {}
48/// fn histogram(&self, _name: &str, _labels: &[(&str, &str)], _value: f64) {}
49/// }
50///
51/// let sink = CountingSink::default();
52/// sink.counter("rustrade_fills_routed_total", &[], 1);
53/// sink.inc("rustrade_fills_routed_total");
54/// assert_eq!(sink.counters.lock().unwrap()["rustrade_fills_routed_total"], 2);
55/// ```
56pub trait MetricsSink: Send + Sync + 'static {
57 /// Increment a counter by `value`.
58 fn counter(&self, name: &str, labels: &[(&str, &str)], value: u64);
59
60 /// Record a gauge value.
61 fn gauge(&self, name: &str, labels: &[(&str, &str)], value: f64);
62
63 /// Observe a histogram sample.
64 fn histogram(&self, name: &str, labels: &[(&str, &str)], value: f64);
65
66 /// Convenience: increment by 1 with no labels.
67 fn inc(&self, name: &str) {
68 self.counter(name, &[], 1);
69 }
70}
71
72/// Default sink that discards everything. Lower overhead than even a
73/// `tracing::trace!` call.
74#[derive(Debug, Default, Clone, Copy)]
75pub struct NoopSink;
76
77impl MetricsSink for NoopSink {
78 fn counter(&self, _name: &str, _labels: &[(&str, &str)], _value: u64) {}
79 fn gauge(&self, _name: &str, _labels: &[(&str, &str)], _value: f64) {}
80 fn histogram(&self, _name: &str, _labels: &[(&str, &str)], _value: f64) {}
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use std::sync::Arc;
87 use std::sync::atomic::{AtomicU64, Ordering};
88
89 struct CountingSink {
90 counters: AtomicU64,
91 gauges: AtomicU64,
92 histograms: AtomicU64,
93 }
94
95 impl MetricsSink for CountingSink {
96 fn counter(&self, _name: &str, _labels: &[(&str, &str)], _value: u64) {
97 self.counters.fetch_add(1, Ordering::SeqCst);
98 }
99 fn gauge(&self, _name: &str, _labels: &[(&str, &str)], _value: f64) {
100 self.gauges.fetch_add(1, Ordering::SeqCst);
101 }
102 fn histogram(&self, _name: &str, _labels: &[(&str, &str)], _value: f64) {
103 self.histograms.fetch_add(1, Ordering::SeqCst);
104 }
105 }
106
107 #[test]
108 fn noop_sink_no_panics() {
109 let s = NoopSink;
110 s.counter("a", &[], 1);
111 s.gauge("a", &[("x", "y")], 1.5);
112 s.histogram("a", &[], 2.0);
113 s.inc("a");
114 }
115
116 #[test]
117 fn inc_default_delegates_to_counter() {
118 let s: Arc<CountingSink> = Arc::new(CountingSink {
119 counters: AtomicU64::new(0),
120 gauges: AtomicU64::new(0),
121 histograms: AtomicU64::new(0),
122 });
123 s.inc("x");
124 assert_eq!(s.counters.load(Ordering::SeqCst), 1);
125 assert_eq!(s.gauges.load(Ordering::SeqCst), 0);
126 }
127
128 #[test]
129 fn arc_dyn_sink_is_object_safe() {
130 let s: Arc<dyn MetricsSink> = Arc::new(NoopSink);
131 s.counter("a", &[], 1);
132 }
133}