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