Skip to main content

objectstore_metrics/
mock.rs

1//! Mock metrics recorder for tests.
2//!
3//! Provides [`with_capturing_test_client`], which installs a thread-local
4//! recorder that captures all emitted metrics as DogStatsD-format strings.
5
6use std::future::Future;
7use std::sync::{Arc, Mutex};
8
9use metrics::{Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit};
10
11/// Runs `f` with a thread-local mock recorder installed, then returns all
12/// captured metrics as `"name:value|type|#key:value,key:value"` strings.
13///
14/// Only affects the calling thread — safe for use in parallel tests.
15///
16/// # Example
17///
18/// ```ignore
19/// let captured = objectstore_metrics::with_capturing_test_client(|| {
20///     objectstore_metrics::counter!("test.counter": 1, "tag" => "val");
21/// });
22/// assert!(captured.iter().any(|m| m.starts_with("test.counter:")));
23/// ```
24pub fn with_capturing_test_client(f: impl FnOnce()) -> Vec<String> {
25    let recorder = MockRecorder::default();
26    metrics::with_local_recorder(&recorder, f);
27    recorder.consume()
28}
29
30/// Awaits `future` with a thread-local mock recorder installed, then returns all captured
31/// metrics as `"name:value|type|#key:value,key:value"` strings.
32///
33/// The recorder stays installed across await points, so metrics emitted while the future is
34/// suspended are captured as well. Since it is thread-local, the future must not migrate between
35/// threads: run it on a current-thread runtime, as `#[tokio::test]` does by default.
36///
37/// # Example
38///
39/// ```ignore
40/// let captured = objectstore_metrics::with_capturing_test_client_async(async {
41///     objectstore_metrics::count!("test.counter");
42/// })
43/// .await;
44/// assert!(captured.iter().any(|m| m.starts_with("test.counter:")));
45/// ```
46pub async fn with_capturing_test_client_async(future: impl Future<Output = ()>) -> Vec<String> {
47    let recorder = MockRecorder::default();
48    let guard = metrics::set_default_local_recorder(&recorder);
49    future.await;
50    drop(guard);
51    recorder.consume()
52}
53
54/// A metrics recorder that formats and stores every operation as a string.
55#[derive(Clone, Default)]
56struct MockRecorder {
57    inner: Arc<Mutex<Vec<String>>>,
58}
59
60impl MockRecorder {
61    /// Drains and returns all captured metric strings.
62    fn consume(self) -> Vec<String> {
63        self.inner
64            .lock()
65            .unwrap_or_else(|e| e.into_inner())
66            .drain(..)
67            .collect()
68    }
69}
70
71impl Recorder for MockRecorder {
72    fn describe_counter(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
73    fn describe_gauge(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
74    fn describe_histogram(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
75
76    fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter {
77        Counter::from_arc(Arc::new(MockFn::new(key.clone(), self.inner.clone())))
78    }
79
80    fn register_gauge(&self, key: &Key, _metadata: &Metadata<'_>) -> Gauge {
81        Gauge::from_arc(Arc::new(MockFn::new(key.clone(), self.inner.clone())))
82    }
83
84    fn register_histogram(&self, key: &Key, _metadata: &Metadata<'_>) -> Histogram {
85        Histogram::from_arc(Arc::new(MockFn::new(key.clone(), self.inner.clone())))
86    }
87}
88
89/// Shared implementation for all metric types that formats and records operations.
90struct MockFn {
91    key: Key,
92    inner: Arc<Mutex<Vec<String>>>,
93}
94
95impl MockFn {
96    fn new(key: Key, inner: Arc<Mutex<Vec<String>>>) -> Self {
97        Self { key, inner }
98    }
99
100    fn push(&self, value: &str, ty: &str) {
101        let labels = self
102            .key
103            .labels()
104            .map(|l| format!("{}:{}", l.key(), l.value()))
105            .collect::<Vec<_>>()
106            .join(",");
107
108        let entry = if labels.is_empty() {
109            format!("{}:{}|{}", self.key.name(), value, ty)
110        } else {
111            format!("{}:{}|{}|#{}", self.key.name(), value, ty, labels)
112        };
113
114        if let Ok(mut vec) = self.inner.lock() {
115            vec.push(entry);
116        }
117    }
118}
119
120impl metrics::CounterFn for MockFn {
121    fn increment(&self, value: u64) {
122        self.push(&format!("+{value}"), "c");
123    }
124
125    fn absolute(&self, value: u64) {
126        self.push(&format!("={value}"), "c");
127    }
128}
129
130impl metrics::GaugeFn for MockFn {
131    fn increment(&self, value: f64) {
132        self.push(&format!("+{value}"), "g");
133    }
134
135    fn decrement(&self, value: f64) {
136        self.push(&format!("-{value}"), "g");
137    }
138
139    fn set(&self, value: f64) {
140        self.push(&format!("{value}"), "g");
141    }
142}
143
144impl metrics::HistogramFn for MockFn {
145    fn record(&self, value: f64) {
146        self.push(&format!("{value}"), "d");
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn captures_counter() {
156        let captured = with_capturing_test_client(|| {
157            crate::count!("test.counter");
158        });
159        assert_eq!(captured.len(), 1);
160        assert_eq!(captured[0], "test.counter:+1|c");
161    }
162
163    #[test]
164    fn captures_counter_with_tags() {
165        let captured = with_capturing_test_client(|| {
166            crate::count!("test.counter", env = "prod", region = "us");
167        });
168        assert_eq!(captured.len(), 1);
169        assert_eq!(captured[0], "test.counter:+1|c|#env:prod,region:us");
170    }
171
172    #[test]
173    fn captures_gauge() {
174        let captured = with_capturing_test_client(|| {
175            crate::gauge!("test.gauge" = 42usize);
176        });
177        assert_eq!(captured.len(), 1);
178        assert_eq!(captured[0], "test.gauge:42|g");
179    }
180
181    #[test]
182    fn captures_gauge_increment() {
183        let captured = with_capturing_test_client(|| {
184            crate::gauge!("test.gauge" += 5usize);
185        });
186        assert_eq!(captured.len(), 1);
187        assert_eq!(captured[0], "test.gauge:+5|g");
188    }
189
190    #[test]
191    fn captures_gauge_decrement() {
192        let captured = with_capturing_test_client(|| {
193            crate::gauge!("test.gauge" -= 3usize);
194        });
195        assert_eq!(captured.len(), 1);
196        assert_eq!(captured[0], "test.gauge:-3|g");
197    }
198
199    #[test]
200    fn captures_distribution() {
201        let captured = with_capturing_test_client(|| {
202            crate::record!("test.dist" = 2.78f64);
203        });
204        assert_eq!(captured.len(), 1);
205        assert_eq!(captured[0], "test.dist:2.78|d");
206    }
207
208    #[test]
209    fn captures_distribution_seconds() {
210        let captured = with_capturing_test_client(|| {
211            let dur = std::time::Duration::from_millis(1500);
212            crate::record!("test.latency" = dur);
213        });
214        assert_eq!(captured.len(), 1);
215        assert_eq!(captured[0], "test.latency:1.5|d");
216    }
217
218    #[test]
219    fn captures_counter_explicit_increment() {
220        let captured = with_capturing_test_client(|| {
221            crate::count!("test.counter" += 5);
222        });
223        assert_eq!(captured.len(), 1);
224        assert_eq!(captured[0], "test.counter:+5|c");
225    }
226
227    #[test]
228    fn captures_distribution_with_tags() {
229        let captured = with_capturing_test_client(|| {
230            let dur = std::time::Duration::from_secs(2);
231            crate::record!("test.latency" = dur, route = "/v1/test", method = "GET",);
232        });
233        assert_eq!(captured.len(), 1);
234        assert_eq!(captured[0], "test.latency:2|d|#route:/v1/test,method:GET");
235    }
236
237    #[test]
238    fn timer_record_emits_success_true() {
239        let captured = with_capturing_test_client(|| {
240            let guard = crate::timer!("test.timer");
241            guard.record();
242        });
243        assert_eq!(captured.len(), 1);
244        assert!(captured[0].starts_with("test.timer:"));
245        assert!(captured[0].contains("|d|#success:true"));
246    }
247
248    #[test]
249    fn timer_drop_emits_success_false() {
250        let captured = with_capturing_test_client(|| {
251            let _guard = crate::timer!("test.timer");
252        });
253        assert_eq!(captured.len(), 1);
254        assert!(captured[0].starts_with("test.timer:"));
255        assert!(captured[0].contains("|d|#success:false"));
256    }
257
258    #[test]
259    fn timer_drop_with_success_emits_success_true() {
260        let captured = with_capturing_test_client(|| {
261            let _guard = crate::timer!("test.timer").success();
262        });
263        assert_eq!(captured.len(), 1);
264        assert!(captured[0].starts_with("test.timer:"));
265        assert!(captured[0].contains("|d|#success:true"));
266    }
267
268    #[test]
269    fn timer_with_tags() {
270        let captured = with_capturing_test_client(|| {
271            let guard = crate::timer!("test.timer", route = "/v1/test");
272            guard.record();
273        });
274        assert_eq!(captured.len(), 1);
275        assert!(captured[0].starts_with("test.timer:"));
276        assert!(captured[0].contains("route:/v1/test"));
277        assert!(captured[0].contains("success:true"));
278    }
279
280    #[test]
281    fn timer_drop_with_tags() {
282        let captured = with_capturing_test_client(|| {
283            let _guard = crate::timer!("test.timer", op = "put");
284        });
285        assert_eq!(captured.len(), 1);
286        assert!(captured[0].contains("op:put"));
287        assert!(captured[0].contains("success:false"));
288    }
289
290    #[test]
291    fn timer_deferred_tag_on_record() {
292        let captured = with_capturing_test_client(|| {
293            let guard = crate::timer!("test.timer", usecase = "test");
294            guard.tag("backend", "gcs").record();
295        });
296        assert_eq!(captured.len(), 1);
297        assert!(captured[0].contains("usecase:test"));
298        assert!(captured[0].contains("backend:gcs"));
299        assert!(captured[0].contains("success:true"));
300    }
301
302    #[test]
303    fn timer_deferred_tag_on_drop() {
304        let captured = with_capturing_test_client(|| {
305            let _guard = crate::timer!("test.timer", usecase = "test").tag("backend", "gcs");
306        });
307        assert_eq!(captured.len(), 1);
308        assert!(captured[0].contains("usecase:test"));
309        assert!(captured[0].contains("backend:gcs"));
310        assert!(captured[0].contains("success:false"));
311    }
312}