Skip to main content

lean_ctx/core/
telemetry.rs

1//! Telemetry and metrics collection following OpenTelemetry GenAI conventions.
2//!
3//! Provides zero-allocation metrics collection with a short consistency lock
4//! around tool-call counters so daily snapshots cannot lose partial updates:
5//! - Token usage (input, output, saved, compression ratio)
6//! - Tool call latency and success rates
7//! - Search quality metrics (latency, result counts)
8//! - Embedding inference performance
9//! - Cache hit/miss rates
10//!
11//! Naming follows the OpenTelemetry GenAI Semantic Conventions:
12//! <https://opentelemetry.io/docs/specs/semconv/gen-ai/>
13
14use 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
24/// Distinct (tool, failure template) pairs one process keeps.
25pub(crate) const MAX_FAILURE_TEMPLATES: usize = 256;
26
27/// Calls, failures and summed latency of one tool since process start.
28#[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    /// Failures by [`crate::core::telemetry_failure::FAILURE_KINDS`] index.
34    pub failure_kinds: [u64; crate::core::telemetry_failure::FAILURE_KINDS.len()],
35    /// Class of the most recent call's failure; `None` after a success.
36    pub last_failure: Option<crate::core::telemetry_failure::FailureKind>,
37    /// Calls made right after a failed call of the same tool, and how many
38    /// of those failed again.
39    pub retries: u64,
40    pub retry_failures: u64,
41}
42
43/// How the agent session served by this process went so far, from the
44/// in-process counters only. Feeds the session-end tally in
45/// `core::telemetry_features`; nothing here leaves the machine as is.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub(crate) struct SessionOutcome {
48    pub calls: u64,
49    pub failures: u64,
50    /// Failure classes of tools whose last call failed.
51    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    /// Guards the tool-call counters so a daily snapshot never sees a
63    /// half-applied call, and holds the per-tool counts. Keys are the static
64    /// names of the built-in tool registry, never caller-supplied strings.
65    tool_call_consistency: std::sync::Mutex<BTreeMap<&'static str, ToolCallCounter>>,
66    /// Scrubbed failure templates per built-in tool since process start.
67    failure_templates: std::sync::Mutex<BTreeMap<(&'static str, String), u64>>,
68    // gen_ai.usage.input_tokens / gen_ai.usage.output_tokens
69    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    /// Record a call of a built-in tool: the totals and the tool's own counter
139    /// move together under one lock.
140    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    /// Like [`Self::record_named_tool_call`], with the class of a failure.
149    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    /// Like [`Self::record_named_tool_outcome`], also counting the failure's
178    /// scrubbed template. Templates are bounded per process.
179    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    /// Format as OpenTelemetry-compatible attributes for logging.
344    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    /// Tokens tool output would have cost uncompressed vs. what was delivered.
402    pub tokens_input: u64,
403    pub tokens_output: u64,
404    /// Scrubbed failure templates by (tool, template).
405    pub failure_templates: BTreeMap<(&'static str, String), u64>,
406}
407
408/// Point-in-time snapshot of all metrics.
409#[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
475/// RAII guard that records tool call latency on drop.
476pub 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
502// ---------------------------------------------------------------------------
503// Prometheus text format export (Zero-PII)
504// ---------------------------------------------------------------------------
505
506impl 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        // Verified savings from the hash-chained ledger (GL #401): the honest
602        // FinOps numbers — measured baselines only, no counterfactual factors.
603        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        // Resource info metric (GL #401): one constant series carrying the
621        // drill-down tags (project, profile, role, model, version) in the
622        // kube-state-metrics `_info` idiom. Deliberately a single series per
623        // process — per-metric label combinations would explode Datadog
624        // custom-metric cardinality (and the customer's bill).
625        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
633/// Escape a Prometheus label value (backslash, double quote, newline).
634fn escape_label(v: &str) -> String {
635    v.replace('\\', "\\\\")
636        .replace('"', "\\\"")
637        .replace('\n', "\\n")
638}
639
640/// Deployment tags shared by the Prometheus `lean_ctx_info` series and the
641/// Datadog push exporter. Values are bounded: project is the cwd basename
642/// (never the full path), model/profile/role come from bounded registries —
643/// cardinality stays at one series per running process.
644pub 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
660/// The `lean_ctx_info` label set in Prometheus exposition syntax.
661fn info_labels() -> String {
662    info_tags()
663        .iter()
664        .map(|(k, v)| format!("{k}=\"{}\"", escape_label(v)))
665        .collect::<Vec<_>>()
666        .join(",")
667}
668
669/// Ledger totals with a 30 s cache: `summarize()` streams the whole JSONL —
670/// fine once, wasteful for per-minute scrapes hitting `/metrics`.
671pub(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}