1use std::collections::BTreeMap;
15use std::sync::OnceLock;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::Instant;
18
19static METRICS: OnceLock<Metrics> = OnceLock::new();
20
21pub(crate) const TOOL_LATENCY_BUCKET_UPPER_MS: [u64; 9] =
22 [10, 50, 100, 250, 500, 1_000, 5_000, 60_000, i64::MAX as u64];
23
24pub(crate) const MAX_FAILURE_TEMPLATES: usize = 256;
26
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub(crate) struct ToolCallCounter {
30 pub calls: u64,
31 pub failures: u64,
32 pub latency_us: u64,
33 pub failure_kinds: [u64; crate::core::telemetry_failure::FAILURE_KINDS.len()],
35 pub last_failure: Option<crate::core::telemetry_failure::FailureKind>,
37 pub retries: u64,
40 pub retry_failures: u64,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub(crate) struct SessionOutcome {
48 pub calls: u64,
49 pub failures: u64,
50 pub unresolved: Vec<crate::core::telemetry_failure::FailureKind>,
52 pub retries: u64,
53 pub retry_failures: u64,
54}
55
56pub fn global_metrics() -> &'static Metrics {
57 METRICS.get_or_init(Metrics::new)
58}
59
60#[derive(Debug)]
61pub struct Metrics {
62 tool_call_consistency: std::sync::Mutex<BTreeMap<&'static str, ToolCallCounter>>,
66 failure_templates: std::sync::Mutex<BTreeMap<(&'static str, String), u64>>,
68 pub tokens_input: AtomicU64,
70 pub tokens_output: AtomicU64,
71 pub tokens_saved: AtomicU64,
72
73 pub tool_calls_total: AtomicU64,
74 pub tool_calls_error: AtomicU64,
75 pub tool_call_latency_sum_us: AtomicU64,
76 tool_call_latency_buckets: [AtomicU64; TOOL_LATENCY_BUCKET_UPPER_MS.len()],
77
78 pub search_queries_total: AtomicU64,
79 pub search_latency_sum_us: AtomicU64,
80 pub search_results_total: AtomicU64,
81
82 pub embedding_inferences_total: AtomicU64,
83 pub embedding_latency_sum_us: AtomicU64,
84 pub embedding_tokens_total: AtomicU64,
85
86 pub cache_hits: AtomicU64,
87 pub cache_misses: AtomicU64,
88
89 pub compression_calls: AtomicU64,
90 pub compression_input_bytes: AtomicU64,
91 pub compression_output_bytes: AtomicU64,
92
93 pub session_start: Instant,
94}
95
96impl Default for Metrics {
97 fn default() -> Self {
98 Self {
99 tool_call_consistency: std::sync::Mutex::new(BTreeMap::new()),
100 failure_templates: std::sync::Mutex::new(BTreeMap::new()),
101 tokens_input: AtomicU64::new(0),
102 tokens_output: AtomicU64::new(0),
103 tokens_saved: AtomicU64::new(0),
104 tool_calls_total: AtomicU64::new(0),
105 tool_calls_error: AtomicU64::new(0),
106 tool_call_latency_sum_us: AtomicU64::new(0),
107 tool_call_latency_buckets: std::array::from_fn(|_| AtomicU64::new(0)),
108 search_queries_total: AtomicU64::new(0),
109 search_latency_sum_us: AtomicU64::new(0),
110 search_results_total: AtomicU64::new(0),
111 embedding_inferences_total: AtomicU64::new(0),
112 embedding_latency_sum_us: AtomicU64::new(0),
113 embedding_tokens_total: AtomicU64::new(0),
114 cache_hits: AtomicU64::new(0),
115 cache_misses: AtomicU64::new(0),
116 compression_calls: AtomicU64::new(0),
117 compression_input_bytes: AtomicU64::new(0),
118 compression_output_bytes: AtomicU64::new(0),
119 session_start: Instant::now(),
120 }
121 }
122}
123
124impl Metrics {
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 pub fn record_tool_call(&self, latency_us: u64, success: bool) {
130 let guard = self
131 .tool_call_consistency
132 .lock()
133 .unwrap_or_else(std::sync::PoisonError::into_inner);
134 self.record_tool_call_locked(latency_us, success);
135 drop(guard);
136 }
137
138 pub fn record_named_tool_call(&self, tool: &'static str, latency_us: u64, success: bool) {
141 self.record_named_tool_outcome(
142 tool,
143 latency_us,
144 (!success).then_some(crate::core::telemetry_failure::FailureKind::Other),
145 );
146 }
147
148 pub fn record_named_tool_outcome(
150 &self,
151 tool: &'static str,
152 latency_us: u64,
153 failure: Option<crate::core::telemetry_failure::FailureKind>,
154 ) {
155 let mut per_tool = self
156 .tool_call_consistency
157 .lock()
158 .unwrap_or_else(std::sync::PoisonError::into_inner);
159 self.record_tool_call_locked(latency_us, failure.is_none());
160 let counter = per_tool.entry(tool).or_default();
161 counter.calls = counter.calls.saturating_add(1);
162 counter.latency_us = counter.latency_us.saturating_add(latency_us);
163 if counter.last_failure.is_some() {
164 counter.retries = counter.retries.saturating_add(1);
165 if failure.is_some() {
166 counter.retry_failures = counter.retry_failures.saturating_add(1);
167 }
168 }
169 counter.last_failure = failure;
170 if let Some(kind) = failure {
171 counter.failures = counter.failures.saturating_add(1);
172 let slot = &mut counter.failure_kinds[kind.index()];
173 *slot = slot.saturating_add(1);
174 }
175 }
176
177 pub fn record_named_tool_failure(
180 &self,
181 tool: &'static str,
182 latency_us: u64,
183 failure: Option<crate::core::telemetry_failure::Failure>,
184 ) {
185 let template = failure
186 .as_ref()
187 .and_then(|failure| failure.template.clone());
188 self.record_named_tool_outcome(tool, latency_us, failure.map(|failure| failure.kind));
189 let Some(template) = template else { return };
190 let mut templates = self
191 .failure_templates
192 .lock()
193 .unwrap_or_else(std::sync::PoisonError::into_inner);
194 let key = (tool, template);
195 if templates.len() < MAX_FAILURE_TEMPLATES || templates.contains_key(&key) {
196 let count = templates.entry(key).or_default();
197 *count = count.saturating_add(1);
198 }
199 }
200
201 fn record_tool_call_locked(&self, latency_us: u64, success: bool) {
202 self.tool_calls_total.fetch_add(1, Ordering::Relaxed);
203 self.tool_call_latency_sum_us
204 .fetch_add(latency_us, Ordering::Relaxed);
205 let latency_ms = latency_us / 1_000;
206 let bucket = TOOL_LATENCY_BUCKET_UPPER_MS
207 .iter()
208 .position(|upper| latency_ms <= *upper)
209 .unwrap_or(TOOL_LATENCY_BUCKET_UPPER_MS.len() - 1);
210 self.tool_call_latency_buckets[bucket].fetch_add(1, Ordering::Relaxed);
211 if !success {
212 self.tool_calls_error.fetch_add(1, Ordering::Relaxed);
213 }
214 }
215
216 pub fn record_tokens(&self, input: u64, output: u64, saved: u64) {
217 self.tokens_input.fetch_add(input, Ordering::Relaxed);
218 self.tokens_output.fetch_add(output, Ordering::Relaxed);
219 self.tokens_saved.fetch_add(saved, Ordering::Relaxed);
220 }
221
222 pub fn record_search(&self, latency_us: u64, result_count: u64) {
223 self.search_queries_total.fetch_add(1, Ordering::Relaxed);
224 self.search_latency_sum_us
225 .fetch_add(latency_us, Ordering::Relaxed);
226 self.search_results_total
227 .fetch_add(result_count, Ordering::Relaxed);
228 }
229
230 pub fn record_embedding(&self, latency_us: u64, token_count: u64) {
231 self.embedding_inferences_total
232 .fetch_add(1, Ordering::Relaxed);
233 self.embedding_latency_sum_us
234 .fetch_add(latency_us, Ordering::Relaxed);
235 self.embedding_tokens_total
236 .fetch_add(token_count, Ordering::Relaxed);
237 }
238
239 pub fn record_cache(&self, hit: bool) {
240 if hit {
241 self.cache_hits.fetch_add(1, Ordering::Relaxed);
242 } else {
243 self.cache_misses.fetch_add(1, Ordering::Relaxed);
244 }
245 }
246
247 pub fn record_compression(&self, input_bytes: u64, output_bytes: u64) {
248 self.compression_calls.fetch_add(1, Ordering::Relaxed);
249 self.compression_input_bytes
250 .fetch_add(input_bytes, Ordering::Relaxed);
251 self.compression_output_bytes
252 .fetch_add(output_bytes, Ordering::Relaxed);
253 }
254
255 pub fn snapshot(&self) -> MetricsSnapshot {
256 let tool_calls = self.tool_calls_total.load(Ordering::Relaxed);
257 let tool_latency = self.tool_call_latency_sum_us.load(Ordering::Relaxed);
258 let cache_hits = self.cache_hits.load(Ordering::Relaxed);
259 let cache_misses = self.cache_misses.load(Ordering::Relaxed);
260 let comp_in = self.compression_input_bytes.load(Ordering::Relaxed);
261 let comp_out = self.compression_output_bytes.load(Ordering::Relaxed);
262
263 MetricsSnapshot {
264 tokens_input: self.tokens_input.load(Ordering::Relaxed),
265 tokens_output: self.tokens_output.load(Ordering::Relaxed),
266 tokens_saved: self.tokens_saved.load(Ordering::Relaxed),
267 tool_calls_total: tool_calls,
268 tool_calls_error: self.tool_calls_error.load(Ordering::Relaxed),
269 tool_call_avg_latency_ms: if tool_calls > 0 {
270 tool_latency as f64 / tool_calls as f64 / 1000.0
271 } else {
272 0.0
273 },
274 search_queries_total: self.search_queries_total.load(Ordering::Relaxed),
275 search_avg_latency_ms: {
276 let q = self.search_queries_total.load(Ordering::Relaxed);
277 if q > 0 {
278 self.search_latency_sum_us.load(Ordering::Relaxed) as f64 / q as f64 / 1000.0
279 } else {
280 0.0
281 }
282 },
283 embedding_inferences: self.embedding_inferences_total.load(Ordering::Relaxed),
284 embedding_avg_latency_ms: {
285 let e = self.embedding_inferences_total.load(Ordering::Relaxed);
286 if e > 0 {
287 self.embedding_latency_sum_us.load(Ordering::Relaxed) as f64 / e as f64 / 1000.0
288 } else {
289 0.0
290 }
291 },
292 cache_hit_rate: if cache_hits + cache_misses > 0 {
293 cache_hits as f64 / (cache_hits + cache_misses) as f64
294 } else {
295 0.0
296 },
297 compression_ratio: if comp_in > 0 {
298 1.0 - (comp_out as f64 / comp_in as f64)
299 } else {
300 0.0
301 },
302 session_uptime_secs: self.session_start.elapsed().as_secs(),
303 }
304 }
305
306 pub(crate) fn daily_telemetry_snapshot(&self) -> DailyTelemetrySnapshot {
307 let per_tool = self
308 .tool_call_consistency
309 .lock()
310 .unwrap_or_else(std::sync::PoisonError::into_inner);
311 DailyTelemetrySnapshot {
312 per_tool: per_tool.clone(),
313 tool_calls: self.tool_calls_total.load(Ordering::Relaxed),
314 tool_failures: self.tool_calls_error.load(Ordering::Relaxed),
315 tool_latency_buckets: std::array::from_fn(|index| {
316 self.tool_call_latency_buckets[index].load(Ordering::Relaxed)
317 }),
318 session_uptime_secs: self.session_start.elapsed().as_secs(),
319 tokens_input: self.tokens_input.load(Ordering::Relaxed),
320 tokens_output: self.tokens_output.load(Ordering::Relaxed),
321 failure_templates: self
322 .failure_templates
323 .lock()
324 .unwrap_or_else(std::sync::PoisonError::into_inner)
325 .clone(),
326 }
327 }
328
329 pub(crate) fn session_outcome(&self) -> SessionOutcome {
330 let per_tool = self
331 .tool_call_consistency
332 .lock()
333 .unwrap_or_else(std::sync::PoisonError::into_inner);
334 SessionOutcome {
335 calls: self.tool_calls_total.load(Ordering::Relaxed),
336 failures: self.tool_calls_error.load(Ordering::Relaxed),
337 unresolved: per_tool.values().filter_map(|c| c.last_failure).collect(),
338 retries: per_tool.values().map(|c| c.retries).sum(),
339 retry_failures: per_tool.values().map(|c| c.retry_failures).sum(),
340 }
341 }
342
343 pub fn to_otel_attributes(&self) -> Vec<(&'static str, String)> {
345 let snap = self.snapshot();
346 vec![
347 ("gen_ai.usage.input_tokens", snap.tokens_input.to_string()),
348 ("gen_ai.usage.output_tokens", snap.tokens_output.to_string()),
349 ("lean_ctx.tokens.saved", snap.tokens_saved.to_string()),
350 (
351 "lean_ctx.tool.calls.total",
352 snap.tool_calls_total.to_string(),
353 ),
354 (
355 "lean_ctx.tool.calls.error",
356 snap.tool_calls_error.to_string(),
357 ),
358 (
359 "lean_ctx.tool.latency_avg_ms",
360 format!("{:.2}", snap.tool_call_avg_latency_ms),
361 ),
362 (
363 "lean_ctx.search.queries",
364 snap.search_queries_total.to_string(),
365 ),
366 (
367 "lean_ctx.search.latency_avg_ms",
368 format!("{:.2}", snap.search_avg_latency_ms),
369 ),
370 (
371 "lean_ctx.embedding.inferences",
372 snap.embedding_inferences.to_string(),
373 ),
374 (
375 "lean_ctx.embedding.latency_avg_ms",
376 format!("{:.2}", snap.embedding_avg_latency_ms),
377 ),
378 (
379 "lean_ctx.cache.hit_rate",
380 format!("{:.4}", snap.cache_hit_rate),
381 ),
382 (
383 "lean_ctx.compression.ratio",
384 format!("{:.4}", snap.compression_ratio),
385 ),
386 (
387 "lean_ctx.session.uptime_secs",
388 snap.session_uptime_secs.to_string(),
389 ),
390 ]
391 }
392}
393
394#[derive(Debug, Clone)]
395pub(crate) struct DailyTelemetrySnapshot {
396 pub tool_calls: u64,
397 pub tool_failures: u64,
398 pub tool_latency_buckets: [u64; TOOL_LATENCY_BUCKET_UPPER_MS.len()],
399 pub session_uptime_secs: u64,
400 pub per_tool: BTreeMap<&'static str, ToolCallCounter>,
401 pub tokens_input: u64,
403 pub tokens_output: u64,
404 pub failure_templates: BTreeMap<(&'static str, String), u64>,
406}
407
408#[derive(Debug, Clone)]
410pub struct MetricsSnapshot {
411 pub tokens_input: u64,
412 pub tokens_output: u64,
413 pub tokens_saved: u64,
414 pub tool_calls_total: u64,
415 pub tool_calls_error: u64,
416 pub tool_call_avg_latency_ms: f64,
417 pub search_queries_total: u64,
418 pub search_avg_latency_ms: f64,
419 pub embedding_inferences: u64,
420 pub embedding_avg_latency_ms: f64,
421 pub cache_hit_rate: f64,
422 pub compression_ratio: f64,
423 pub session_uptime_secs: u64,
424}
425
426impl MetricsSnapshot {
427 pub fn to_compact_string(&self) -> String {
428 format!(
429 "tok={}/{}/{} calls={}/{} search={} embed={} cache={:.0}% comp={:.0}% up={}s",
430 self.tokens_input,
431 self.tokens_output,
432 self.tokens_saved,
433 self.tool_calls_total,
434 self.tool_calls_error,
435 self.search_queries_total,
436 self.embedding_inferences,
437 self.cache_hit_rate * 100.0,
438 self.compression_ratio * 100.0,
439 self.session_uptime_secs,
440 )
441 }
442
443 pub fn to_json(&self) -> String {
444 serde_json::json!({
445 "gen_ai": {
446 "usage": {
447 "input_tokens": self.tokens_input,
448 "output_tokens": self.tokens_output,
449 }
450 },
451 "lean_ctx": {
452 "tokens": { "saved": self.tokens_saved },
453 "tool": {
454 "calls_total": self.tool_calls_total,
455 "calls_error": self.tool_calls_error,
456 "avg_latency_ms": self.tool_call_avg_latency_ms,
457 },
458 "search": {
459 "queries": self.search_queries_total,
460 "avg_latency_ms": self.search_avg_latency_ms,
461 },
462 "embedding": {
463 "inferences": self.embedding_inferences,
464 "avg_latency_ms": self.embedding_avg_latency_ms,
465 },
466 "cache": { "hit_rate": self.cache_hit_rate },
467 "compression": { "ratio": self.compression_ratio },
468 "session": { "uptime_secs": self.session_uptime_secs },
469 }
470 })
471 .to_string()
472 }
473}
474
475pub struct ToolCallTimer {
477 start: Instant,
478 tool_name: &'static str,
479}
480
481impl ToolCallTimer {
482 pub fn new(tool_name: &'static str) -> Self {
483 Self {
484 start: Instant::now(),
485 tool_name,
486 }
487 }
488
489 pub fn finish(self, success: bool) {
490 let elapsed = self.start.elapsed();
491 let us = elapsed.as_micros() as u64;
492 global_metrics().record_tool_call(us, success);
493 tracing::debug!(
494 tool = self.tool_name,
495 latency_ms = elapsed.as_millis() as u64,
496 success,
497 "tool_call"
498 );
499 }
500}
501
502impl Metrics {
507 pub fn to_prometheus(&self) -> String {
508 let snap = self.snapshot();
509
510 let mut lines = Vec::with_capacity(32);
511
512 lines.push("# HELP lean_ctx_tokens_saved_total Total tokens saved by compression".into());
513 lines.push("# TYPE lean_ctx_tokens_saved_total counter".into());
514 lines.push(format!("lean_ctx_tokens_saved_total {}", snap.tokens_saved));
515
516 lines.push("# HELP lean_ctx_tokens_input_total Total input tokens processed".into());
517 lines.push("# TYPE lean_ctx_tokens_input_total counter".into());
518 lines.push(format!("lean_ctx_tokens_input_total {}", snap.tokens_input));
519
520 lines.push("# HELP lean_ctx_tokens_output_total Total output tokens generated".into());
521 lines.push("# TYPE lean_ctx_tokens_output_total counter".into());
522 lines.push(format!(
523 "lean_ctx_tokens_output_total {}",
524 snap.tokens_output
525 ));
526
527 lines.push("# HELP lean_ctx_compression_ratio Current compression ratio".into());
528 lines.push("# TYPE lean_ctx_compression_ratio gauge".into());
529 lines.push(format!(
530 "lean_ctx_compression_ratio {:.4}",
531 snap.compression_ratio
532 ));
533
534 lines.push("# HELP lean_ctx_tool_calls_total Total tool calls".into());
535 lines.push("# TYPE lean_ctx_tool_calls_total counter".into());
536 lines.push(format!(
537 "lean_ctx_tool_calls_total {}",
538 snap.tool_calls_total
539 ));
540
541 lines.push("# HELP lean_ctx_tool_calls_error_total Total failed tool calls".into());
542 lines.push("# TYPE lean_ctx_tool_calls_error_total counter".into());
543 lines.push(format!(
544 "lean_ctx_tool_calls_error_total {}",
545 snap.tool_calls_error
546 ));
547
548 lines.push("# HELP lean_ctx_cache_hit_rate Cache hit rate (0-1)".into());
549 lines.push("# TYPE lean_ctx_cache_hit_rate gauge".into());
550 lines.push(format!(
551 "lean_ctx_cache_hit_rate {:.4}",
552 snap.cache_hit_rate
553 ));
554
555 let verify_snap = crate::core::output_verification::stats_snapshot();
556 lines.push("# HELP lean_ctx_verification_pass_total Total verification passes".into());
557 lines.push("# TYPE lean_ctx_verification_pass_total counter".into());
558 lines.push(format!(
559 "lean_ctx_verification_pass_total {}",
560 verify_snap.pass
561 ));
562
563 lines.push("# HELP lean_ctx_verification_warn_total Total verification warnings".into());
564 lines.push("# TYPE lean_ctx_verification_warn_total counter".into());
565 lines.push(format!(
566 "lean_ctx_verification_warn_total {}",
567 verify_snap.warn_items
568 ));
569
570 lines.push(
571 "# HELP lean_ctx_verification_warn_runs_total Total runs with verification warnings"
572 .into(),
573 );
574 lines.push("# TYPE lean_ctx_verification_warn_runs_total counter".into());
575 lines.push(format!(
576 "lean_ctx_verification_warn_runs_total {}",
577 verify_snap.warn_runs
578 ));
579
580 lines.push("# HELP lean_ctx_verification_pass_rate Verification pass rate".into());
581 lines.push("# TYPE lean_ctx_verification_pass_rate gauge".into());
582 lines.push(format!(
583 "lean_ctx_verification_pass_rate {:.4}",
584 verify_snap.pass_rate
585 ));
586
587 lines.push("# HELP lean_ctx_info_loss_score Average info loss score (0..1)".into());
588 lines.push("# TYPE lean_ctx_info_loss_score gauge".into());
589 lines.push(format!(
590 "lean_ctx_info_loss_score {:.6}",
591 verify_snap.avg_info_loss_score
592 ));
593
594 lines.push("# HELP lean_ctx_session_uptime_seconds Session uptime in seconds".into());
595 lines.push("# TYPE lean_ctx_session_uptime_seconds gauge".into());
596 lines.push(format!(
597 "lean_ctx_session_uptime_seconds {}",
598 snap.session_uptime_secs
599 ));
600
601 let (ledger_tokens, ledger_usd) = ledger_totals_cached();
604 lines.push(
605 "# HELP lean_ctx_ledger_tokens_saved_total Verified tokens saved (signed ledger)"
606 .into(),
607 );
608 lines.push("# TYPE lean_ctx_ledger_tokens_saved_total counter".into());
609 lines.push(format!(
610 "lean_ctx_ledger_tokens_saved_total {ledger_tokens}"
611 ));
612
613 lines.push(
614 "# HELP lean_ctx_cost_saved_usd_total Verified cost saved in USD (signed ledger)"
615 .into(),
616 );
617 lines.push("# TYPE lean_ctx_cost_saved_usd_total counter".into());
618 lines.push(format!("lean_ctx_cost_saved_usd_total {ledger_usd:.4}"));
619
620 lines.push("# HELP lean_ctx_info Deployment metadata for tag joins".into());
626 lines.push("# TYPE lean_ctx_info gauge".into());
627 lines.push(format!("lean_ctx_info{{{}}} 1", info_labels()));
628
629 lines.join("\n") + "\n"
630 }
631}
632
633fn escape_label(v: &str) -> String {
635 v.replace('\\', "\\\\")
636 .replace('"', "\\\"")
637 .replace('\n', "\\n")
638}
639
640pub fn info_tags() -> Vec<(&'static str, String)> {
645 let project = std::env::current_dir()
646 .ok()
647 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
648 .unwrap_or_else(|| "unknown".to_string());
649 let model = crate::core::host_state::load_detected_model()
650 .map_or_else(|| "unknown".to_string(), |(name, _)| name);
651 vec![
652 ("project", project),
653 ("profile", crate::core::profiles::active_profile_name()),
654 ("agent_role", crate::core::roles::active_role_name()),
655 ("model", model),
656 ("version", env!("CARGO_PKG_VERSION").to_string()),
657 ]
658}
659
660fn info_labels() -> String {
662 info_tags()
663 .iter()
664 .map(|(k, v)| format!("{k}=\"{}\"", escape_label(v)))
665 .collect::<Vec<_>>()
666 .join(",")
667}
668
669pub(crate) fn ledger_totals_cached() -> (u64, f64) {
672 use std::sync::Mutex;
673 use std::time::{Duration, Instant};
674 static CACHE: Mutex<Option<(Instant, (u64, f64))>> = Mutex::new(None);
675
676 let Ok(mut guard) = CACHE.lock() else {
677 return (0, 0.0);
678 };
679 if let Some((at, totals)) = *guard
680 && at.elapsed() < Duration::from_secs(30)
681 {
682 return totals;
683 }
684 let summary = crate::core::savings_ledger::summary();
685 let totals = (summary.net_saved_tokens(), summary.saved_usd);
686 *guard = Some((Instant::now(), totals));
687 totals
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn record_tool_call() {
696 let m = Metrics::new();
697 m.record_tool_call(5000, true);
698 m.record_tool_call(3000, false);
699
700 let snap = m.snapshot();
701 assert_eq!(snap.tool_calls_total, 2);
702 assert_eq!(snap.tool_calls_error, 1);
703 assert!(snap.tool_call_avg_latency_ms > 0.0);
704 }
705
706 #[test]
707 fn named_tool_call_moves_totals_and_per_tool_counter_together() {
708 let m = Metrics::new();
709 m.record_named_tool_call("ctx_read", 1_000, true);
710 m.record_named_tool_call("ctx_read", 2_000, false);
711 m.record_named_tool_call("ctx_shell", 3_000, true);
712
713 let snap = m.daily_telemetry_snapshot();
714 assert_eq!(snap.tool_calls, 3);
715 assert_eq!(snap.tool_failures, 1);
716 assert_eq!(
717 snap.per_tool.get("ctx_read"),
718 Some(&ToolCallCounter {
719 calls: 2,
720 failures: 1,
721 latency_us: 3_000,
722 failure_kinds: {
723 let mut kinds = [0; crate::core::telemetry_failure::FAILURE_KINDS.len()];
724 kinds[crate::core::telemetry_failure::FailureKind::Other.index()] = 1;
725 kinds
726 },
727 last_failure: Some(crate::core::telemetry_failure::FailureKind::Other),
728 retries: 0,
729 retry_failures: 0,
730 })
731 );
732 assert_eq!(
733 snap.per_tool.get("ctx_shell"),
734 Some(&ToolCallCounter {
735 calls: 1,
736 failures: 0,
737 latency_us: 3_000,
738 ..ToolCallCounter::default()
739 })
740 );
741 }
742
743 #[test]
744 fn session_outcome_tracks_retries_and_unresolved_tools() {
745 use crate::core::telemetry_failure::FailureKind;
746 let m = Metrics::new();
747 m.record_named_tool_outcome("ctx_read", 1, Some(FailureKind::NotFound));
748 m.record_named_tool_outcome("ctx_read", 1, Some(FailureKind::NotFound));
749 m.record_named_tool_outcome("ctx_read", 1, None);
750 m.record_named_tool_outcome("ctx_shell", 1, None);
751 m.record_named_tool_outcome("ctx_shell", 1, Some(FailureKind::Timeout));
752
753 assert_eq!(
754 m.session_outcome(),
755 SessionOutcome {
756 calls: 5,
757 failures: 3,
758 unresolved: vec![FailureKind::Timeout],
759 retries: 2,
760 retry_failures: 1,
761 }
762 );
763 }
764
765 #[test]
766 fn concurrent_daily_snapshot_never_observes_partial_tool_call() {
767 let metrics = std::sync::Arc::new(Metrics::new());
768 let writers = (0..4)
769 .map(|worker| {
770 let metrics = std::sync::Arc::clone(&metrics);
771 std::thread::spawn(move || {
772 for call in 0..1_000 {
773 metrics.record_tool_call((call + 1) * 1_000, (call + worker) % 3 != 0);
774 }
775 })
776 })
777 .collect::<Vec<_>>();
778 while writers.iter().any(|writer| !writer.is_finished()) {
779 let snapshot = metrics.daily_telemetry_snapshot();
780 assert_eq!(
781 snapshot.tool_latency_buckets.iter().sum::<u64>(),
782 snapshot.tool_calls
783 );
784 assert!(snapshot.tool_failures <= snapshot.tool_calls);
785 }
786 for writer in writers {
787 writer.join().expect("writer");
788 }
789 let snapshot = metrics.daily_telemetry_snapshot();
790 assert_eq!(snapshot.tool_calls, 4_000);
791 assert_eq!(
792 snapshot.tool_latency_buckets.iter().sum::<u64>(),
793 snapshot.tool_calls
794 );
795 assert!(snapshot.tool_failures <= snapshot.tool_calls);
796 }
797
798 #[test]
799 fn record_tokens() {
800 let m = Metrics::new();
801 m.record_tokens(100, 50, 200);
802 m.record_tokens(150, 75, 300);
803
804 let snap = m.snapshot();
805 assert_eq!(snap.tokens_input, 250);
806 assert_eq!(snap.tokens_output, 125);
807 assert_eq!(snap.tokens_saved, 500);
808 }
809
810 #[test]
811 fn record_search() {
812 let m = Metrics::new();
813 m.record_search(2000, 5);
814 m.record_search(4000, 3);
815
816 let snap = m.snapshot();
817 assert_eq!(snap.search_queries_total, 2);
818 assert!((snap.search_avg_latency_ms - 3.0).abs() < 0.01);
819 }
820
821 #[test]
822 fn cache_hit_rate() {
823 let m = Metrics::new();
824 m.record_cache(true);
825 m.record_cache(true);
826 m.record_cache(false);
827
828 let snap = m.snapshot();
829 assert!((snap.cache_hit_rate - 0.6667).abs() < 0.01);
830 }
831
832 #[test]
833 fn compression_ratio() {
834 let m = Metrics::new();
835 m.record_compression(1000, 200);
836
837 let snap = m.snapshot();
838 assert!((snap.compression_ratio - 0.8).abs() < 0.01);
839 }
840
841 #[test]
842 fn snapshot_compact_string() {
843 let m = Metrics::new();
844 m.record_tokens(100, 50, 200);
845 m.record_tool_call(5000, true);
846 let compact = m.snapshot().to_compact_string();
847 assert!(compact.contains("tok=100/50/200"));
848 assert!(compact.contains("calls=1/0"));
849 }
850
851 #[test]
852 fn snapshot_json() {
853 let m = Metrics::new();
854 m.record_tokens(100, 50, 200);
855 let json = m.snapshot().to_json();
856 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
857 assert_eq!(parsed["gen_ai"]["usage"]["input_tokens"], 100);
858 assert_eq!(parsed["lean_ctx"]["tokens"]["saved"], 200);
859 }
860
861 #[test]
862 fn otel_attributes() {
863 let m = Metrics::new();
864 m.record_tokens(100, 50, 200);
865 let attrs = m.to_otel_attributes();
866 assert!(
867 attrs
868 .iter()
869 .any(|(k, v)| *k == "gen_ai.usage.input_tokens" && v == "100")
870 );
871 }
872
873 #[test]
874 fn global_metrics_singleton() {
875 let m1 = global_metrics();
876 let m2 = global_metrics();
877 assert!(std::ptr::eq(m1, m2));
878 }
879
880 #[test]
881 fn tool_call_timer() {
882 let timer = ToolCallTimer::new("test_tool");
883 std::thread::sleep(std::time::Duration::from_millis(5));
884 timer.finish(true);
885 }
886}