Skip to main content

swink_agent/
metrics.rs

1//! Structured per-turn metrics and observability.
2//!
3//! The [`MetricsCollector`] trait receives a [`TurnMetrics`] snapshot at the
4//! end of each agent loop turn, capturing LLM call duration, per-tool timing,
5//! token usage breakdowns, and cost attribution.
6
7use std::future::Future;
8use std::pin::Pin;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use crate::types::{Cost, Usage};
14
15// ─── ToolExecMetrics ────────────────────────────────────────────────────────
16
17/// Timing and outcome data for a single tool execution within a turn.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ToolExecMetrics {
20    /// Name of the tool that was executed.
21    pub tool_name: String,
22    /// Wall-clock duration of the tool execution.
23    pub duration: Duration,
24    /// Whether the tool execution succeeded (`true`) or returned an error.
25    pub success: bool,
26}
27
28// ─── TurnMetrics ────────────────────────────────────────────────────────────
29
30/// Metrics snapshot emitted at the end of each agent loop turn.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TurnMetrics {
33    /// Zero-based index of the turn within the current run.
34    pub turn_index: usize,
35    /// Wall-clock duration of the LLM streaming call (excludes tool execution).
36    pub llm_call_duration: Duration,
37    /// Per-tool execution metrics for this turn (empty if no tools were called).
38    pub tool_executions: Vec<ToolExecMetrics>,
39    /// Token usage for this turn's LLM call.
40    pub usage: Usage,
41    /// Cost attributed to this turn's LLM call.
42    pub cost: Cost,
43    /// Total wall-clock duration of the entire turn (LLM + tools).
44    pub turn_duration: Duration,
45}
46
47// ─── MetricsCollector Trait ─────────────────────────────────────────────────
48
49pub type MetricsFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
50///
51/// Async observer that receives structured metrics at the end of each turn.
52///
53/// Implementations can persist metrics, forward to monitoring systems, or
54/// accumulate for post-run analysis.
55///
56/// # Example
57///
58/// ```rust
59/// use swink_agent::{MetricsCollector, MetricsFuture, TurnMetrics};
60///
61/// struct LogMetrics;
62///
63/// impl MetricsCollector for LogMetrics {
64///     fn on_metrics<'a>(
65///         &'a self,
66///         metrics: &'a TurnMetrics,
67///     ) -> MetricsFuture<'a> {
68///         Box::pin(async move {
69///             println!("Turn {}: LLM took {:?}", metrics.turn_index, metrics.llm_call_duration);
70///         })
71///     }
72/// }
73/// ```
74pub trait MetricsCollector: Send + Sync {
75    /// Called at the end of each turn with the collected metrics.
76    fn on_metrics<'a>(&'a self, metrics: &'a TurnMetrics) -> MetricsFuture<'a>;
77}
78
79// ─── Compile-time Send + Sync assertions ────────────────────────────────────
80
81const _: () = {
82    const fn assert_send_sync<T: Send + Sync>() {}
83    assert_send_sync::<ToolExecMetrics>();
84    assert_send_sync::<TurnMetrics>();
85};
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::sync::Arc;
91    use std::sync::atomic::{AtomicUsize, Ordering};
92
93    struct CountingCollector {
94        count: AtomicUsize,
95    }
96
97    impl MetricsCollector for CountingCollector {
98        fn on_metrics<'a>(&'a self, _metrics: &'a TurnMetrics) -> MetricsFuture<'a> {
99            Box::pin(async move {
100                self.count.fetch_add(1, Ordering::SeqCst);
101            })
102        }
103    }
104
105    #[tokio::test]
106    async fn collector_receives_metrics() {
107        let collector = CountingCollector {
108            count: AtomicUsize::new(0),
109        };
110        let metrics = TurnMetrics {
111            turn_index: 0,
112            llm_call_duration: Duration::from_millis(150),
113            tool_executions: vec![
114                ToolExecMetrics {
115                    tool_name: "bash".into(),
116                    duration: Duration::from_millis(50),
117                    success: true,
118                },
119                ToolExecMetrics {
120                    tool_name: "read_file".into(),
121                    duration: Duration::from_millis(10),
122                    success: false,
123                },
124            ],
125            usage: Usage {
126                input: 100,
127                output: 50,
128                total: 150,
129                ..Default::default()
130            },
131            cost: Cost {
132                input: 0.001,
133                output: 0.002,
134                total: 0.003,
135                ..Default::default()
136            },
137            turn_duration: Duration::from_millis(210),
138        };
139        collector.on_metrics(&metrics).await;
140        assert_eq!(collector.count.load(Ordering::SeqCst), 1);
141    }
142
143    #[tokio::test]
144    async fn metrics_captures_tool_details() {
145        let metrics = TurnMetrics {
146            turn_index: 2,
147            llm_call_duration: Duration::from_secs(1),
148            tool_executions: vec![ToolExecMetrics {
149                tool_name: "bash".into(),
150                duration: Duration::from_millis(500),
151                success: true,
152            }],
153            usage: Usage::default(),
154            cost: Cost::default(),
155            turn_duration: Duration::from_millis(1500),
156        };
157        assert_eq!(metrics.tool_executions.len(), 1);
158        assert_eq!(metrics.tool_executions[0].tool_name, "bash");
159        assert!(metrics.tool_executions[0].success);
160        assert_eq!(metrics.turn_index, 2);
161    }
162
163    #[tokio::test]
164    async fn arc_collector_is_send_sync() {
165        let collector: Arc<dyn MetricsCollector> = Arc::new(CountingCollector {
166            count: AtomicUsize::new(0),
167        });
168        let metrics = TurnMetrics {
169            turn_index: 0,
170            llm_call_duration: Duration::ZERO,
171            tool_executions: vec![],
172            usage: Usage::default(),
173            cost: Cost::default(),
174            turn_duration: Duration::ZERO,
175        };
176        collector.on_metrics(&metrics).await;
177    }
178
179    #[test]
180    fn turn_metrics_serde_roundtrip() {
181        let metrics = TurnMetrics {
182            turn_index: 1,
183            llm_call_duration: Duration::from_millis(200),
184            tool_executions: vec![ToolExecMetrics {
185                tool_name: "write_file".into(),
186                duration: Duration::from_millis(30),
187                success: true,
188            }],
189            usage: Usage {
190                input: 50,
191                output: 25,
192                total: 75,
193                ..Default::default()
194            },
195            cost: Cost {
196                total: 0.005,
197                ..Default::default()
198            },
199            turn_duration: Duration::from_millis(230),
200        };
201        let json = serde_json::to_string(&metrics).unwrap();
202        let parsed: TurnMetrics = serde_json::from_str(&json).unwrap();
203        assert_eq!(parsed.turn_index, 1);
204        assert_eq!(parsed.tool_executions.len(), 1);
205        assert_eq!(parsed.usage.input, 50);
206    }
207}