//! Monotonic request metrics. Stage names are static labels, never queries/paths. use serde::{Deserialize, Serialize}; use std::{ collections::BTreeMap, sync::atomic::{AtomicU64, Ordering}, time::Instant, }; const MAX_SPANS: usize = 512; static COLLECTOR_ID: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResponse { pub results: Vec, pub metrics: SearchMetrics, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchMetrics { pub total_ms: f64, pub result_count: usize, pub trace: Option>, #[serde(default)] pub dropped_spans: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchSpan { pub name: String, pub start_ms: f64, pub duration_ms: f64, pub parent: Option, pub counters: BTreeMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MetricsMode { Basic, Debug, } #[derive(Debug, Clone, Copy)] pub struct SpanToken { collector: u64, index: usize, } impl SpanToken { pub fn index(self) -> usize { self.index } } struct OpenSpan { value: SearchSpan, started: Instant, ended: bool, } struct DebugTrace { id: u64, spans: Vec, dropped: u64, } /// Basic mode stores only one Instant: no span Vec or stage/counter allocation. pub struct TraceCollector { started: Instant, debug: Option, } impl TraceCollector { pub fn new(mode: MetricsMode) -> Self { Self { started: Instant::now(), debug: match mode { MetricsMode::Basic => None, MetricsMode::Debug => Some(DebugTrace { id: COLLECTOR_ID.fetch_add(1, Ordering::Relaxed), spans: Vec::new(), dropped: 0, }), }, } } pub fn elapsed_ms(&self) -> f64 { self.started.elapsed().as_secs_f64() * 1000.0 } pub fn is_debug(&self) -> bool { self.debug.is_some() } pub fn begin(&mut self, name: &'static str, parent: Option) -> Option { let debug = self.debug.as_mut()?; let parent_index = match parent { Some(token) if token.collector == debug.id && debug.spans.get(token.index).is_some_and(|span| !span.ended) => { Some(token.index) } Some(_) => { debug.dropped = debug.dropped.saturating_add(1); return None; } None => None, }; if debug.spans.len() >= MAX_SPANS { debug.dropped = debug.dropped.saturating_add(1); return None; } let started = Instant::now(); let index = debug.spans.len(); debug.spans.push(OpenSpan { value: SearchSpan { name: name.chars().take(96).collect(), start_ms: started.duration_since(self.started).as_secs_f64() * 1000.0, duration_ms: 0.0, parent: parent_index, counters: BTreeMap::new(), }, started, ended: false, }); Some(SpanToken { collector: debug.id, index, }) } /// Invalid/previously ended tokens are harmless. Closing a parent also /// closes open descendants at the same instant, preserving containment. pub fn end(&mut self, token: Option, counters: BTreeMap) { let Some(token) = token else { return; }; let Some(debug) = self.debug.as_mut() else { return; }; if token.collector != debug.id || token.index >= debug.spans.len() || debug.spans[token.index].ended { return; } let now = Instant::now(); for index in token.index..debug.spans.len() { let mut ancestor = Some(index); let mut contained = false; while let Some(current) = ancestor { if current == token.index { contained = true; break; } ancestor = debug.spans[current].value.parent; } if contained && !debug.spans[index].ended { let span = &mut debug.spans[index]; span.value.duration_ms = now.duration_since(span.started).as_secs_f64() * 1000.0; span.ended = true; } } // At most32 distinct bounded keys; callers should use static diagnostic // names and counts, never source text or paths. let stored = &mut debug.spans[token.index].value.counters; for (key, value) in counters.into_iter().take(32) { let key: String = key.chars().take(96).collect(); if stored.len() < 32 || stored.contains_key(&key) { stored.insert(key, value); } } } pub fn counter(&mut self, token: Option, name: &'static str, value: u64) { let Some(token) = token else { return; }; let Some(debug) = self.debug.as_mut() else { return; }; if token.collector != debug.id { return; } let Some(span) = debug.spans.get_mut(token.index) else { return; }; if span.value.counters.len() < 32 || span.value.counters.contains_key(name) { span.value .counters .insert(name.chars().take(96).collect(), value); } } /// Return the closure's value unchanged, including any Result error. /// The closure receives its parent token for safe nested instrumentation. pub fn timed( &mut self, name: &'static str, parent: Option, run: impl FnOnce(&mut Self, Option) -> R, ) -> R { let token = self.begin(name, parent); let value = run(self, token); self.end(token, BTreeMap::new()); value } pub fn finish(mut self, result_count: usize) -> SearchMetrics { let now = Instant::now(); if let Some(debug) = self.debug.as_mut() { for span in &mut debug.spans { if !span.ended { span.value.duration_ms = now.duration_since(span.started).as_secs_f64() * 1000.0; span.ended = true; } } } let (trace, dropped_spans) = match self.debug { Some(debug) => ( Some(debug.spans.into_iter().map(|span| span.value).collect()), debug.dropped, ), None => (None, 0), }; SearchMetrics { total_ms: now.duration_since(self.started).as_secs_f64() * 1000.0, result_count, trace, dropped_spans, } } } #[cfg(test)] mod tests { use super::*; #[test] fn nesting_contains_children_and_preserves_error() { let mut trace = TraceCollector::new(MetricsMode::Debug); let result: Result<(), &str> = trace.timed("request", None, |trace, parent| { trace.timed("index", parent, |trace, child| { trace.counter(child, "rows", 7); Err("failure") }) }); assert_eq!(result, Err("failure")); let metrics = trace.finish(0); let spans = metrics.trace.unwrap(); assert_eq!(spans[1].parent, Some(0)); assert_eq!(spans[1].counters["rows"], 7); assert!(spans[1].start_ms >= spans[0].start_ms); assert!( spans[1].start_ms + spans[1].duration_ms <= spans[0].start_ms + spans[0].duration_ms + 1e-6 ); assert!(metrics.total_ms >= spans[0].start_ms + spans[0].duration_ms - 1e-6); } #[test] fn basic_disables_stage_storage_and_preserves_counts() { let mut trace = TraceCollector::new(MetricsMode::Basic); assert!(!trace.is_debug()); assert!(trace.begin("unused", None).is_none()); assert_eq!(trace.timed("unused", None, |_, _| 42), 42); let metrics = trace.finish(3); assert_eq!(metrics.result_count, 3); assert!(metrics.trace.is_none()); assert_eq!(metrics.dropped_spans, 0); } #[test] fn trace_is_capped_and_reports_dropped_spans() { let mut trace = TraceCollector::new(MetricsMode::Debug); for index in 0..520 { let token = trace.begin("stage", None); if index == 0 { trace.counter(token, "existing", 99); } let counters = if index == 0 { (0..40).map(|key| (format!("counter_{key}"), key)).collect() } else { BTreeMap::new() }; trace.end(token, counters); } let metrics = trace.finish(2); let spans = metrics.trace.unwrap(); assert_eq!(spans.len(), 512); assert_eq!(spans[0].counters.len(), 32); assert_eq!(spans[0].counters["existing"], 99); assert_eq!(metrics.dropped_spans, 8); } #[test] fn foreign_tokens_and_early_parent_close_are_safe() { let mut one = TraceCollector::new(MetricsMode::Debug); let mut two = TraceCollector::new(MetricsMode::Debug); let root = one.begin("root", None); let child = one.begin("child", root); assert!(two.begin("foreign", root).is_none()); one.end(root, BTreeMap::new()); one.end(child, BTreeMap::new()); let spans = one.finish(0).trace.unwrap(); assert!( spans[1].start_ms + spans[1].duration_ms <= spans[0].start_ms + spans[0].duration_ms + 1e-6 ); } }