Skip to main content

lc_core/observability/
agent_metrics.rs

1// lc-core/src/observability/agent_metrics.rs
2//! Aggregated metrics for a single agent execution.
3//!
4//! Moved from `lc-agents` (v0.20.2) so the unified observability payload
5//! (`ObsEvent`) can reference it from `lc-core`. `lc-agents` keeps a one-line
6//! re-export for path compatibility.
7
8use crate::language_models::TokenUsage;
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12/// Aggregated metrics for a single agent execution.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct AgentMetrics {
15    /// trace_id of this execution (`None` if absent).
16    pub trace_id: Option<String>,
17    /// Number of LLM planning calls (plan iterations).
18    pub llm_calls: usize,
19    /// Number of LLM result cache hits that skipped an LLM call.
20    pub cache_hits: usize,
21    /// Number of tool executions (including parallel).
22    pub tool_calls: usize,
23    /// Number of context compactions performed during the run (0.21.0 S6.1).
24    /// `#[serde(default)]` keeps pre-0.21.0 payloads deserializable.
25    #[serde(default)]
26    pub compactions: usize,
27    /// Cumulative token usage (`None` if the agent does not report tokens).
28    pub total_tokens: Option<usize>,
29    /// Total duration of a single invoke.
30    pub duration: Duration,
31}
32
33impl AgentMetrics {
34    /// Accumulates token usage from one LLM call.
35    pub fn add_token_usage(&mut self, usage: &TokenUsage) {
36        self.total_tokens = Some(self.total_tokens.unwrap_or(0) + usage.total_tokens);
37    }
38
39    /// Average tokens per LLM call.
40    pub fn tokens_per_call(&self) -> Option<f64> {
41        if self.llm_calls == 0 {
42            return None;
43        }
44        self.total_tokens.map(|t| t as f64 / self.llm_calls as f64)
45    }
46
47    /// Emits the metrics audit-log line (target: `lc_agents::metrics`).
48    pub fn log_summary(&self) {
49        let trace = self.trace_id.as_deref().unwrap_or("-");
50        let duration_ms = self.duration.as_millis();
51        match self.total_tokens {
52            Some(tokens) => log::info!(
53                target: "lc_agents::metrics",
54                "agent_exec summary trace_id={} llm_calls={} tool_calls={} total_tokens={} duration_ms={}",
55                trace,
56                self.llm_calls,
57                self.tool_calls,
58                tokens,
59                duration_ms
60            ),
61            None => log::info!(
62                target: "lc_agents::metrics",
63                "agent_exec summary trace_id={} llm_calls={} tool_calls={} total_tokens=n/a duration_ms={}",
64                trace,
65                self.llm_calls,
66                self.tool_calls,
67                duration_ms
68            ),
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_metrics_default() {
79        let m = AgentMetrics::default();
80        assert_eq!(m.llm_calls, 0);
81        assert_eq!(m.tool_calls, 0);
82        assert_eq!(m.total_tokens, None);
83        assert_eq!(m.trace_id, None);
84        assert_eq!(m.duration, Duration::ZERO);
85    }
86
87    #[test]
88    fn test_add_token_usage() {
89        let mut m = AgentMetrics::default();
90        m.add_token_usage(&TokenUsage {
91            prompt_tokens: 10,
92            completion_tokens: 5,
93            total_tokens: 15,
94        });
95        m.add_token_usage(&TokenUsage {
96            prompt_tokens: 3,
97            completion_tokens: 2,
98            total_tokens: 5,
99        });
100        assert_eq!(m.total_tokens, Some(20));
101    }
102
103    #[test]
104    fn test_tokens_per_call() {
105        let mut m = AgentMetrics::default();
106        assert_eq!(m.tokens_per_call(), None);
107        m.llm_calls = 2;
108        m.total_tokens = Some(20);
109        assert_eq!(m.tokens_per_call(), Some(10.0));
110    }
111
112    #[test]
113    fn test_tokens_per_call_zero_calls() {
114        let m = AgentMetrics {
115            total_tokens: Some(10),
116            ..Default::default()
117        };
118        assert_eq!(m.tokens_per_call(), None);
119    }
120
121    #[test]
122    fn test_serde_roundtrip() {
123        let m = AgentMetrics {
124            trace_id: Some("trace-x".to_string()),
125            llm_calls: 3,
126            cache_hits: 1,
127            tool_calls: 2,
128            compactions: 1,
129            total_tokens: Some(42),
130            duration: Duration::from_millis(1500),
131        };
132        let json = serde_json::to_string(&m).expect("serialize");
133        let back: AgentMetrics = serde_json::from_str(&json).expect("deserialize");
134        assert_eq!(back.trace_id, m.trace_id);
135        assert_eq!(back.llm_calls, m.llm_calls);
136        assert_eq!(back.cache_hits, m.cache_hits);
137        assert_eq!(back.tool_calls, m.tool_calls);
138        assert_eq!(back.compactions, m.compactions);
139        assert_eq!(back.total_tokens, m.total_tokens);
140        assert_eq!(back.duration, m.duration);
141    }
142
143    /// 0.21.0 S6.1: pre-0.21.0 payloads (no `compactions` field) still deserialize
144    /// — the field is `#[serde(default)]` for backward compatibility.
145    #[test]
146    fn test_serde_roundtrip_without_compactions_field() {
147        let legacy = r#"{
148            "trace_id": "trace-x",
149            "llm_calls": 3,
150            "cache_hits": 1,
151            "tool_calls": 2,
152            "total_tokens": 42,
153            "duration": {"secs": 1, "nanos": 500000000}
154        }"#;
155        let back: AgentMetrics = serde_json::from_str(legacy).expect("legacy payload deserializes");
156        assert_eq!(back.compactions, 0, "missing field defaults to 0");
157    }
158
159    #[test]
160    fn test_log_summary_no_panic() {
161        let m = AgentMetrics {
162            trace_id: Some("trace-x".to_string()),
163            ..Default::default()
164        };
165        m.log_summary();
166        let m = AgentMetrics {
167            total_tokens: Some(42),
168            ..Default::default()
169        };
170        m.log_summary();
171    }
172}