Skip to main content

juncture_tracing/
test_utils.rs

1//! Test utilities for metrics and tracing
2//!
3//! This module provides test helpers for collecting and asserting on metrics
4//! in integration tests.
5
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8
9/// Test metrics collector for use in integration tests
10///
11/// A simple in-memory metrics collector that records counter increments,
12/// histogram values, and gauge settings for test assertions.
13///
14/// # Examples
15///
16/// ```
17/// use juncture_tracing::test_utils::TestMetricsCollector;
18///
19/// let metrics = TestMetricsCollector::new();
20/// metrics.increment_counter("test.counter", 1);
21/// metrics.record_histogram("test.histogram", 42.0);
22/// metrics.set_gauge("test.gauge", 100.0);
23///
24/// assert_eq!(metrics.get_counter("test.counter"), 1);
25/// assert_eq!(metrics.get_histogram_values("test.histogram"), vec![42.0]);
26/// assert_eq!(metrics.get_gauge("test.gauge"), Some(100.0));
27/// ```
28#[derive(Clone, Debug)]
29pub struct TestMetricsCollector {
30    counters: Arc<Mutex<HashMap<String, u64>>>,
31    histogram_values: Arc<Mutex<HashMap<String, Vec<f64>>>>,
32    gauge_values: Arc<Mutex<HashMap<String, f64>>>,
33    /// Labeled counters: `metric_name` -> (sorted labels -> value)
34    #[allow(
35        clippy::type_complexity,
36        reason = "labeled metric storage requires nested HashMap"
37    )]
38    labeled_counters: Arc<Mutex<HashMap<String, HashMap<Vec<(String, String)>, u64>>>>,
39}
40
41impl Default for TestMetricsCollector {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl juncture_core::observability::MetricsCollector for TestMetricsCollector {
48    fn inc_counter(&self, name: &str, value: u64) {
49        self.increment_counter(name, value);
50    }
51
52    fn record_histogram(&self, name: &str, value: f64) {
53        self.record_histogram(name, value);
54    }
55
56    fn set_gauge(&self, name: &str, value: u64) {
57        #[allow(
58            clippy::cast_precision_loss,
59            reason = "gauge values from OTel are u64, stored as f64 in test utility"
60        )]
61        let fval = value as f64;
62        self.set_gauge(name, fval);
63    }
64}
65
66impl TestMetricsCollector {
67    /// Create a new test metrics collector
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use juncture_tracing::test_utils::TestMetricsCollector;
73    ///
74    /// let collector = TestMetricsCollector::new();
75    /// assert_eq!(collector.get_counter("any"), 0);
76    /// ```
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            counters: Arc::new(Mutex::new(HashMap::new())),
81            histogram_values: Arc::new(Mutex::new(HashMap::new())),
82            gauge_values: Arc::new(Mutex::new(HashMap::new())),
83            labeled_counters: Arc::new(Mutex::new(HashMap::new())),
84        }
85    }
86
87    /// Increment a counter metric
88    ///
89    /// Adds the given value to the counter, creating it if it doesn't exist.
90    ///
91    /// # Parameters
92    ///
93    /// * `name` - Counter metric name
94    /// * `value` - Value to add (default is 1)
95    ///
96    /// # Panics
97    ///
98    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use juncture_tracing::test_utils::TestMetricsCollector;
104    ///
105    /// let metrics = TestMetricsCollector::new();
106    /// metrics.increment_counter("my.counter", 1);
107    /// metrics.increment_counter("my.counter", 2);
108    /// assert_eq!(metrics.get_counter("my.counter"), 3);
109    /// ```
110    pub fn increment_counter(&self, name: &str, value: u64) {
111        let mut counters = self.counters.lock().unwrap();
112        *counters.entry(name.to_string()).or_insert(0) += value;
113    }
114
115    /// Record a value in a histogram metric
116    ///
117    /// Adds the value to the histogram's recorded values.
118    ///
119    /// # Parameters
120    ///
121    /// * `name` - Histogram metric name
122    /// * `value` - Value to record
123    ///
124    /// # Panics
125    ///
126    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use juncture_tracing::test_utils::TestMetricsCollector;
132    ///
133    /// let metrics = TestMetricsCollector::new();
134    /// metrics.record_histogram("latency_ms", 100.0);
135    /// metrics.record_histogram("latency_ms", 200.0);
136    ///
137    /// let values = metrics.get_histogram_values("latency_ms");
138    /// assert_eq!(values.len(), 2);
139    /// assert_eq!(values[0], 100.0);
140    /// assert_eq!(values[1], 200.0);
141    /// ```
142    pub fn record_histogram(&self, name: &str, value: f64) {
143        let mut histograms = self.histogram_values.lock().unwrap();
144        histograms.entry(name.to_string()).or_default().push(value);
145    }
146
147    /// Set a gauge metric to a specific value
148    ///
149    /// # Parameters
150    ///
151    /// * `name` - Gauge metric name
152    /// * `value` - Value to set
153    ///
154    /// # Panics
155    ///
156    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use juncture_tracing::test_utils::TestMetricsCollector;
162    ///
163    /// let metrics = TestMetricsCollector::new();
164    /// metrics.set_gauge("temperature", 98.6);
165    /// metrics.set_gauge("temperature", 99.1);
166    ///
167    /// assert_eq!(metrics.get_gauge("temperature"), Some(99.1));
168    /// ```
169    pub fn set_gauge(&self, name: &str, value: f64) {
170        let mut gauges = self.gauge_values.lock().unwrap();
171        gauges.insert(name.to_string(), value);
172    }
173
174    /// Get the current value of a counter metric
175    ///
176    /// Returns 0 if the counter has never been incremented.
177    ///
178    /// # Parameters
179    ///
180    /// * `name` - Counter metric name
181    ///
182    /// # Panics
183    ///
184    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use juncture_tracing::test_utils::TestMetricsCollector;
190    ///
191    /// let metrics = TestMetricsCollector::new();
192    /// assert_eq!(metrics.get_counter("test"), 0);
193    ///
194    /// metrics.increment_counter("test", 5);
195    /// assert_eq!(metrics.get_counter("test"), 5);
196    /// ```
197    #[must_use]
198    pub fn get_counter(&self, name: &str) -> u64 {
199        let counters = self.counters.lock().unwrap();
200        counters.get(name).copied().unwrap_or(0)
201    }
202
203    /// Get all recorded values for a histogram metric
204    ///
205    /// Returns an empty vector if the histogram has no values.
206    ///
207    /// # Parameters
208    ///
209    /// * `name` - Histogram metric name
210    ///
211    /// # Panics
212    ///
213    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
214    ///
215    /// # Examples
216    ///
217    /// ```
218    /// use juncture_tracing::test_utils::TestMetricsCollector;
219    ///
220    /// let metrics = TestMetricsCollector::new();
221    /// assert!(metrics.get_histogram_values("test").is_empty());
222    ///
223    /// metrics.record_histogram("test", 1.0);
224    /// assert_eq!(metrics.get_histogram_values("test"), vec![1.0]);
225    /// ```
226    #[must_use]
227    pub fn get_histogram_values(&self, name: &str) -> Vec<f64> {
228        let histograms = self.histogram_values.lock().unwrap();
229        histograms.get(name).cloned().unwrap_or_default()
230    }
231
232    /// Get the current value of a gauge metric
233    ///
234    /// Returns `None` if the gauge has never been set.
235    ///
236    /// # Parameters
237    ///
238    /// * `name` - Gauge metric name
239    ///
240    /// # Panics
241    ///
242    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use juncture_tracing::test_utils::TestMetricsCollector;
248    ///
249    /// let metrics = TestMetricsCollector::new();
250    /// assert_eq!(metrics.get_gauge("test"), None);
251    ///
252    /// metrics.set_gauge("test", 42.0);
253    /// assert_eq!(metrics.get_gauge("test"), Some(42.0));
254    /// ```
255    #[must_use]
256    pub fn get_gauge(&self, name: &str) -> Option<f64> {
257        let gauges = self.gauge_values.lock().unwrap();
258        gauges.get(name).copied()
259    }
260
261    /// Clear all recorded metrics
262    ///
263    /// Useful for resetting state between test cases.
264    ///
265    /// # Panics
266    ///
267    /// Panics if any internal mutex is poisoned (should not happen in normal usage).
268    ///
269    /// # Examples
270    ///
271    /// ```
272    /// use juncture_tracing::test_utils::TestMetricsCollector;
273    ///
274    /// let metrics = TestMetricsCollector::new();
275    /// metrics.increment_counter("test", 5);
276    /// metrics.clear();
277    /// assert_eq!(metrics.get_counter("test"), 0);
278    /// ```
279    #[expect(
280        clippy::significant_drop_tightening,
281        reason = "Locks are held only briefly for clearing"
282    )]
283    pub fn clear(&self) {
284        let mut counters = self.counters.lock().unwrap();
285        let mut histograms = self.histogram_values.lock().unwrap();
286        let mut gauges = self.gauge_values.lock().unwrap();
287        let mut labeled = self.labeled_counters.lock().unwrap();
288
289        counters.clear();
290        histograms.clear();
291        gauges.clear();
292        labeled.clear();
293    }
294
295    /// Get all counter names that have been recorded
296    ///
297    /// # Panics
298    ///
299    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// use juncture_tracing::test_utils::TestMetricsCollector;
305    ///
306    /// let metrics = TestMetricsCollector::new();
307    /// metrics.increment_counter("counter1", 1);
308    /// metrics.increment_counter("counter2", 1);
309    ///
310    /// let names = metrics.counter_names();
311    /// assert_eq!(names.len(), 2);
312    /// assert!(names.contains(&"counter1".to_string()));
313    /// ```
314    #[must_use]
315    pub fn counter_names(&self) -> Vec<String> {
316        let counters = self.counters.lock().unwrap();
317        counters.keys().cloned().collect()
318    }
319
320    /// Get all histogram names that have been recorded
321    ///
322    /// # Panics
323    ///
324    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use juncture_tracing::test_utils::TestMetricsCollector;
330    ///
331    /// let metrics = TestMetricsCollector::new();
332    /// metrics.record_histogram("hist1", 1.0);
333    /// metrics.record_histogram("hist2", 2.0);
334    ///
335    /// let names = metrics.histogram_names();
336    /// assert_eq!(names.len(), 2);
337    /// assert!(names.contains(&"hist1".to_string()));
338    /// ```
339    #[must_use]
340    pub fn histogram_names(&self) -> Vec<String> {
341        let histograms = self.histogram_values.lock().unwrap();
342        histograms.keys().cloned().collect()
343    }
344
345    /// Get all gauge names that have been recorded
346    ///
347    /// # Panics
348    ///
349    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
350    ///
351    /// # Examples
352    ///
353    /// ```
354    /// use juncture_tracing::test_utils::TestMetricsCollector;
355    ///
356    /// let metrics = TestMetricsCollector::new();
357    /// metrics.set_gauge("gauge1", 1.0);
358    /// metrics.set_gauge("gauge2", 2.0);
359    ///
360    /// let names = metrics.gauge_names();
361    /// assert_eq!(names.len(), 2);
362    /// assert!(names.contains(&"gauge1".to_string()));
363    /// ```
364    #[must_use]
365    pub fn gauge_names(&self) -> Vec<String> {
366        let gauges = self.gauge_values.lock().unwrap();
367        gauges.keys().cloned().collect()
368    }
369
370    /// Increment a counter metric with labels
371    ///
372    /// Labels are sorted internally for consistent key matching.
373    ///
374    /// # Panics
375    ///
376    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
377    #[allow(
378        clippy::significant_drop_tightening,
379        reason = "MutexGuard is needed for entry API; tightening would complicate the code"
380    )]
381    pub fn increment_counter_with_labels(
382        &self,
383        name: &str,
384        value: u64,
385        labels: &[(impl ToString, impl ToString)],
386    ) {
387        let key = labels_to_key(labels);
388        let mut labeled = self.labeled_counters.lock().unwrap();
389        let entry = labeled
390            .entry(name.to_string())
391            .or_default()
392            .entry(key)
393            .or_insert(0);
394        *entry = entry.saturating_add(value);
395    }
396
397    /// Get counter value for a specific label set
398    ///
399    /// Returns 0 if no counter with those labels has been recorded.
400    ///
401    /// # Panics
402    ///
403    /// Panics if the internal mutex is poisoned (should not happen in normal usage).
404    #[must_use]
405    pub fn get_counter_with_labels(
406        &self,
407        name: &str,
408        labels: &[(impl ToString, impl ToString)],
409    ) -> u64 {
410        let key = labels_to_key(labels);
411        let labeled = self.labeled_counters.lock().unwrap();
412        labeled
413            .get(name)
414            .and_then(|m| m.get(&key))
415            .copied()
416            .unwrap_or(0)
417    }
418}
419
420/// Convert labels to a sorted Vec key for consistent matching
421fn labels_to_key(labels: &[(impl ToString, impl ToString)]) -> Vec<(String, String)> {
422    let mut key: Vec<(String, String)> = labels
423        .iter()
424        .map(|(k, v)| (k.to_string(), v.to_string()))
425        .collect();
426    key.sort_by(|a, b| a.0.cmp(&b.0));
427    key
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_default() {
436        let collector = TestMetricsCollector::default();
437        assert_eq!(collector.get_counter("test"), 0);
438        assert!(collector.get_histogram_values("test").is_empty());
439        assert_eq!(collector.get_gauge("test"), None);
440    }
441
442    #[test]
443    fn test_increment_counter() {
444        let metrics = TestMetricsCollector::new();
445
446        metrics.increment_counter("test.counter", 1);
447        assert_eq!(metrics.get_counter("test.counter"), 1);
448
449        metrics.increment_counter("test.counter", 2);
450        assert_eq!(metrics.get_counter("test.counter"), 3);
451
452        // Different counter
453        metrics.increment_counter("other.counter", 10);
454        assert_eq!(metrics.get_counter("other.counter"), 10);
455        assert_eq!(metrics.get_counter("test.counter"), 3);
456    }
457
458    #[test]
459    fn test_record_histogram() {
460        let metrics = TestMetricsCollector::new();
461
462        metrics.record_histogram("test.histogram", 1.0);
463        assert_eq!(metrics.get_histogram_values("test.histogram"), vec![1.0]);
464
465        metrics.record_histogram("test.histogram", 2.0);
466        metrics.record_histogram("test.histogram", 3.0);
467
468        let values = metrics.get_histogram_values("test.histogram");
469        assert_eq!(values.len(), 3);
470        assert_eq!(values, vec![1.0, 2.0, 3.0]);
471
472        // Different histogram
473        metrics.record_histogram("other.histogram", 100.0);
474        assert_eq!(metrics.get_histogram_values("other.histogram"), vec![100.0]);
475    }
476
477    #[test]
478    fn test_set_gauge() {
479        let metrics = TestMetricsCollector::new();
480
481        metrics.set_gauge("test.gauge", 50.0);
482        assert_eq!(metrics.get_gauge("test.gauge"), Some(50.0));
483
484        metrics.set_gauge("test.gauge", 75.0);
485        assert_eq!(metrics.get_gauge("test.gauge"), Some(75.0));
486
487        // Different gauge
488        metrics.set_gauge("other.gauge", 100.0);
489        assert_eq!(metrics.get_gauge("other.gauge"), Some(100.0));
490        assert_eq!(metrics.get_gauge("test.gauge"), Some(75.0));
491    }
492
493    #[test]
494    fn test_clear() {
495        let metrics = TestMetricsCollector::new();
496
497        metrics.increment_counter("counter", 5);
498        metrics.record_histogram("histogram", 1.0);
499        metrics.set_gauge("gauge", 10.0);
500
501        metrics.clear();
502
503        assert_eq!(metrics.get_counter("counter"), 0);
504        assert!(metrics.get_histogram_values("histogram").is_empty());
505        assert_eq!(metrics.get_gauge("gauge"), None);
506    }
507
508    #[test]
509    fn test_metric_names() {
510        let metrics = TestMetricsCollector::new();
511
512        metrics.increment_counter("counter1", 1);
513        metrics.increment_counter("counter2", 1);
514
515        let counter_names = metrics.counter_names();
516        assert_eq!(counter_names.len(), 2);
517        assert!(counter_names.contains(&"counter1".to_string()));
518        assert!(counter_names.contains(&"counter2".to_string()));
519
520        metrics.record_histogram("hist1", 1.0);
521        metrics.record_histogram("hist2", 1.0);
522
523        let histogram_names = metrics.histogram_names();
524        assert_eq!(histogram_names.len(), 2);
525        assert!(histogram_names.contains(&"hist1".to_string()));
526
527        metrics.set_gauge("gauge1", 1.0);
528        metrics.set_gauge("gauge2", 1.0);
529
530        let gauge_names = metrics.gauge_names();
531        assert_eq!(gauge_names.len(), 2);
532        assert!(gauge_names.contains(&"gauge1".to_string()));
533    }
534
535    #[test]
536    fn test_clone() {
537        let metrics1 = TestMetricsCollector::new();
538        metrics1.increment_counter("test", 5);
539
540        let metrics2 = metrics1.clone();
541        assert_eq!(metrics2.get_counter("test"), 5);
542
543        // Changes to clone affect original (they share the same Arc)
544        metrics2.increment_counter("test", 3);
545        assert_eq!(metrics1.get_counter("test"), 8);
546    }
547
548    #[test]
549    fn test_labeled_counter() {
550        let metrics = TestMetricsCollector::new();
551
552        metrics.increment_counter_with_labels("juncture.llm.calls", 1, &[("model", "gpt-4")]);
553        metrics.increment_counter_with_labels("juncture.llm.calls", 1, &[("model", "gpt-4")]);
554        metrics.increment_counter_with_labels("juncture.llm.calls", 1, &[("model", "claude")]);
555
556        assert_eq!(
557            metrics.get_counter_with_labels("juncture.llm.calls", &[("model", "gpt-4")]),
558            2
559        );
560        assert_eq!(
561            metrics.get_counter_with_labels("juncture.llm.calls", &[("model", "claude")]),
562            1
563        );
564        assert_eq!(
565            metrics.get_counter_with_labels("juncture.llm.calls", &[("model", "llama")]),
566            0
567        );
568    }
569
570    #[test]
571    fn test_labeled_counter_key_ordering() {
572        let metrics = TestMetricsCollector::new();
573
574        metrics.increment_counter_with_labels("test", 1, &[("b", "2"), ("a", "1")]);
575        assert_eq!(
576            metrics.get_counter_with_labels("test", &[("a", "1"), ("b", "2")]),
577            1
578        );
579    }
580}
581
582// Rust guideline compliant 2026-05-19