use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
pub struct TestMetricsCollector {
counters: Arc<Mutex<HashMap<String, u64>>>,
histogram_values: Arc<Mutex<HashMap<String, Vec<f64>>>>,
gauge_values: Arc<Mutex<HashMap<String, f64>>>,
#[allow(
clippy::type_complexity,
reason = "labeled metric storage requires nested HashMap"
)]
labeled_counters: Arc<Mutex<HashMap<String, HashMap<Vec<(String, String)>, u64>>>>,
}
impl Default for TestMetricsCollector {
fn default() -> Self {
Self::new()
}
}
impl juncture_core::observability::MetricsCollector for TestMetricsCollector {
fn inc_counter(&self, name: &str, value: u64) {
self.increment_counter(name, value);
}
fn record_histogram(&self, name: &str, value: f64) {
self.record_histogram(name, value);
}
fn set_gauge(&self, name: &str, value: u64) {
#[allow(
clippy::cast_precision_loss,
reason = "gauge values from OTel are u64, stored as f64 in test utility"
)]
let fval = value as f64;
self.set_gauge(name, fval);
}
}
impl TestMetricsCollector {
#[must_use]
pub fn new() -> Self {
Self {
counters: Arc::new(Mutex::new(HashMap::new())),
histogram_values: Arc::new(Mutex::new(HashMap::new())),
gauge_values: Arc::new(Mutex::new(HashMap::new())),
labeled_counters: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn increment_counter(&self, name: &str, value: u64) {
let mut counters = self.counters.lock().unwrap();
*counters.entry(name.to_string()).or_insert(0) += value;
}
pub fn record_histogram(&self, name: &str, value: f64) {
let mut histograms = self.histogram_values.lock().unwrap();
histograms.entry(name.to_string()).or_default().push(value);
}
pub fn set_gauge(&self, name: &str, value: f64) {
let mut gauges = self.gauge_values.lock().unwrap();
gauges.insert(name.to_string(), value);
}
#[must_use]
pub fn get_counter(&self, name: &str) -> u64 {
let counters = self.counters.lock().unwrap();
counters.get(name).copied().unwrap_or(0)
}
#[must_use]
pub fn get_histogram_values(&self, name: &str) -> Vec<f64> {
let histograms = self.histogram_values.lock().unwrap();
histograms.get(name).cloned().unwrap_or_default()
}
#[must_use]
pub fn get_gauge(&self, name: &str) -> Option<f64> {
let gauges = self.gauge_values.lock().unwrap();
gauges.get(name).copied()
}
#[expect(
clippy::significant_drop_tightening,
reason = "Locks are held only briefly for clearing"
)]
pub fn clear(&self) {
let mut counters = self.counters.lock().unwrap();
let mut histograms = self.histogram_values.lock().unwrap();
let mut gauges = self.gauge_values.lock().unwrap();
let mut labeled = self.labeled_counters.lock().unwrap();
counters.clear();
histograms.clear();
gauges.clear();
labeled.clear();
}
#[must_use]
pub fn counter_names(&self) -> Vec<String> {
let counters = self.counters.lock().unwrap();
counters.keys().cloned().collect()
}
#[must_use]
pub fn histogram_names(&self) -> Vec<String> {
let histograms = self.histogram_values.lock().unwrap();
histograms.keys().cloned().collect()
}
#[must_use]
pub fn gauge_names(&self) -> Vec<String> {
let gauges = self.gauge_values.lock().unwrap();
gauges.keys().cloned().collect()
}
#[allow(
clippy::significant_drop_tightening,
reason = "MutexGuard is needed for entry API; tightening would complicate the code"
)]
pub fn increment_counter_with_labels(
&self,
name: &str,
value: u64,
labels: &[(impl ToString, impl ToString)],
) {
let key = labels_to_key(labels);
let mut labeled = self.labeled_counters.lock().unwrap();
let entry = labeled
.entry(name.to_string())
.or_default()
.entry(key)
.or_insert(0);
*entry = entry.saturating_add(value);
}
#[must_use]
pub fn get_counter_with_labels(
&self,
name: &str,
labels: &[(impl ToString, impl ToString)],
) -> u64 {
let key = labels_to_key(labels);
let labeled = self.labeled_counters.lock().unwrap();
labeled
.get(name)
.and_then(|m| m.get(&key))
.copied()
.unwrap_or(0)
}
}
fn labels_to_key(labels: &[(impl ToString, impl ToString)]) -> Vec<(String, String)> {
let mut key: Vec<(String, String)> = labels
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
key.sort_by(|a, b| a.0.cmp(&b.0));
key
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default() {
let collector = TestMetricsCollector::default();
assert_eq!(collector.get_counter("test"), 0);
assert!(collector.get_histogram_values("test").is_empty());
assert_eq!(collector.get_gauge("test"), None);
}
#[test]
fn test_increment_counter() {
let metrics = TestMetricsCollector::new();
metrics.increment_counter("test.counter", 1);
assert_eq!(metrics.get_counter("test.counter"), 1);
metrics.increment_counter("test.counter", 2);
assert_eq!(metrics.get_counter("test.counter"), 3);
metrics.increment_counter("other.counter", 10);
assert_eq!(metrics.get_counter("other.counter"), 10);
assert_eq!(metrics.get_counter("test.counter"), 3);
}
#[test]
fn test_record_histogram() {
let metrics = TestMetricsCollector::new();
metrics.record_histogram("test.histogram", 1.0);
assert_eq!(metrics.get_histogram_values("test.histogram"), vec![1.0]);
metrics.record_histogram("test.histogram", 2.0);
metrics.record_histogram("test.histogram", 3.0);
let values = metrics.get_histogram_values("test.histogram");
assert_eq!(values.len(), 3);
assert_eq!(values, vec![1.0, 2.0, 3.0]);
metrics.record_histogram("other.histogram", 100.0);
assert_eq!(metrics.get_histogram_values("other.histogram"), vec![100.0]);
}
#[test]
fn test_set_gauge() {
let metrics = TestMetricsCollector::new();
metrics.set_gauge("test.gauge", 50.0);
assert_eq!(metrics.get_gauge("test.gauge"), Some(50.0));
metrics.set_gauge("test.gauge", 75.0);
assert_eq!(metrics.get_gauge("test.gauge"), Some(75.0));
metrics.set_gauge("other.gauge", 100.0);
assert_eq!(metrics.get_gauge("other.gauge"), Some(100.0));
assert_eq!(metrics.get_gauge("test.gauge"), Some(75.0));
}
#[test]
fn test_clear() {
let metrics = TestMetricsCollector::new();
metrics.increment_counter("counter", 5);
metrics.record_histogram("histogram", 1.0);
metrics.set_gauge("gauge", 10.0);
metrics.clear();
assert_eq!(metrics.get_counter("counter"), 0);
assert!(metrics.get_histogram_values("histogram").is_empty());
assert_eq!(metrics.get_gauge("gauge"), None);
}
#[test]
fn test_metric_names() {
let metrics = TestMetricsCollector::new();
metrics.increment_counter("counter1", 1);
metrics.increment_counter("counter2", 1);
let counter_names = metrics.counter_names();
assert_eq!(counter_names.len(), 2);
assert!(counter_names.contains(&"counter1".to_string()));
assert!(counter_names.contains(&"counter2".to_string()));
metrics.record_histogram("hist1", 1.0);
metrics.record_histogram("hist2", 1.0);
let histogram_names = metrics.histogram_names();
assert_eq!(histogram_names.len(), 2);
assert!(histogram_names.contains(&"hist1".to_string()));
metrics.set_gauge("gauge1", 1.0);
metrics.set_gauge("gauge2", 1.0);
let gauge_names = metrics.gauge_names();
assert_eq!(gauge_names.len(), 2);
assert!(gauge_names.contains(&"gauge1".to_string()));
}
#[test]
fn test_clone() {
let metrics1 = TestMetricsCollector::new();
metrics1.increment_counter("test", 5);
let metrics2 = metrics1.clone();
assert_eq!(metrics2.get_counter("test"), 5);
metrics2.increment_counter("test", 3);
assert_eq!(metrics1.get_counter("test"), 8);
}
#[test]
fn test_labeled_counter() {
let metrics = TestMetricsCollector::new();
metrics.increment_counter_with_labels("juncture.llm.calls", 1, &[("model", "gpt-4")]);
metrics.increment_counter_with_labels("juncture.llm.calls", 1, &[("model", "gpt-4")]);
metrics.increment_counter_with_labels("juncture.llm.calls", 1, &[("model", "claude")]);
assert_eq!(
metrics.get_counter_with_labels("juncture.llm.calls", &[("model", "gpt-4")]),
2
);
assert_eq!(
metrics.get_counter_with_labels("juncture.llm.calls", &[("model", "claude")]),
1
);
assert_eq!(
metrics.get_counter_with_labels("juncture.llm.calls", &[("model", "llama")]),
0
);
}
#[test]
fn test_labeled_counter_key_ordering() {
let metrics = TestMetricsCollector::new();
metrics.increment_counter_with_labels("test", 1, &[("b", "2"), ("a", "1")]);
assert_eq!(
metrics.get_counter_with_labels("test", &[("a", "1"), ("b", "2")]),
1
);
}
}