Skip to main content

ironflow_core/
tracker.rs

1//! Workflow-level cost, token, and duration tracking.
2//!
3//! [`WorkflowTracker`] aggregates metrics across all shell and agent steps in a
4//! workflow, making it easy to log a single summary at the end with total cost,
5//! token counts, and elapsed time.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use ironflow_core::tracker::WorkflowTracker;
11//!
12//! let mut tracker = WorkflowTracker::new("deploy-pipeline");
13//!
14//! // ... run shell and agent steps, calling
15//! // tracker.record_shell() / tracker.record_agent() after each ...
16//!
17//! tracker.summary(); // logs a structured summary via tracing
18//! println!("Total cost: ${:.4}", tracker.total_cost_usd());
19//! ```
20
21use std::collections::VecDeque;
22use std::fmt;
23use std::time::Instant;
24
25use tracing::info;
26
27use crate::operations::agent::AgentResult;
28use crate::operations::http::HttpOutput;
29use crate::operations::shell::ShellOutput;
30
31/// Default maximum number of steps kept in the tracker.
32/// Older steps are evicted when this limit is reached.
33const DEFAULT_MAX_STEPS: usize = 10_000;
34
35/// Aggregates cost, token, and duration metrics for a named workflow.
36///
37/// Create one tracker per workflow run with [`WorkflowTracker::new`], record
38/// each step with [`record_shell`](WorkflowTracker::record_shell) or
39/// [`record_agent`](WorkflowTracker::record_agent), then call
40/// [`summary`](WorkflowTracker::summary) to emit a structured log line.
41pub struct WorkflowTracker {
42    name: String,
43    start: Instant,
44    steps: VecDeque<StepRecord>,
45    max_steps: usize,
46}
47
48struct StepRecord {
49    name: String,
50    kind: StepKind,
51    duration_ms: u64,
52    cost_usd: Option<f64>,
53    input_tokens: Option<u64>,
54    output_tokens: Option<u64>,
55}
56
57enum StepKind {
58    Shell,
59    Http,
60    Agent,
61}
62
63impl fmt::Display for StepKind {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Shell => f.write_str("shell"),
67            Self::Http => f.write_str("http"),
68            Self::Agent => f.write_str("agent"),
69        }
70    }
71}
72
73impl WorkflowTracker {
74    /// Create a new tracker for a workflow with the given `name`.
75    ///
76    /// The wall-clock timer starts immediately.
77    #[must_use = "a tracker does nothing if not used to record steps"]
78    pub fn new(name: &str) -> Self {
79        Self {
80            name: name.to_string(),
81            start: Instant::now(),
82            steps: VecDeque::new(),
83            max_steps: DEFAULT_MAX_STEPS,
84        }
85    }
86
87    /// Set the maximum number of steps to retain.
88    ///
89    /// When exceeded, the oldest step is removed. Defaults to 10 000.
90    pub fn max_steps(mut self, limit: usize) -> Self {
91        self.max_steps = limit;
92        self
93    }
94
95    fn push_step(&mut self, record: StepRecord) {
96        if self.steps.len() >= self.max_steps {
97            self.steps.pop_front();
98        }
99        self.steps.push_back(record);
100    }
101
102    /// Record a completed shell step.
103    ///
104    /// Extracts the duration from the [`ShellOutput`]. Shell steps have no
105    /// associated cost or token counts.
106    pub fn record_shell(&mut self, name: &str, output: &ShellOutput) {
107        self.push_step(StepRecord {
108            name: name.to_string(),
109            kind: StepKind::Shell,
110            duration_ms: output.duration_ms(),
111            cost_usd: None,
112            input_tokens: None,
113            output_tokens: None,
114        });
115    }
116
117    /// Record a completed HTTP step.
118    ///
119    /// Extracts the duration from the [`HttpOutput`]. HTTP steps have no
120    /// associated cost or token counts.
121    pub fn record_http(&mut self, name: &str, output: &HttpOutput) {
122        self.push_step(StepRecord {
123            name: name.to_string(),
124            kind: StepKind::Http,
125            duration_ms: output.duration_ms(),
126            cost_usd: None,
127            input_tokens: None,
128            output_tokens: None,
129        });
130    }
131
132    /// Record a completed agent step.
133    ///
134    /// Extracts duration, cost, and token counts from the [`AgentResult`].
135    pub fn record_agent(&mut self, name: &str, result: &AgentResult) {
136        self.push_step(StepRecord {
137            name: name.to_string(),
138            kind: StepKind::Agent,
139            duration_ms: result.duration_ms(),
140            cost_usd: result.cost_usd(),
141            input_tokens: result.input_tokens(),
142            output_tokens: result.output_tokens(),
143        });
144    }
145
146    /// Return the sum of all agent step costs in USD.
147    ///
148    /// Steps that did not report a cost (including all shell steps) are skipped.
149    pub fn total_cost_usd(&self) -> f64 {
150        self.steps.iter().filter_map(|s| s.cost_usd).sum()
151    }
152
153    /// Return the sum of all input tokens across agent steps.
154    pub fn total_input_tokens(&self) -> u64 {
155        self.steps.iter().filter_map(|s| s.input_tokens).sum()
156    }
157
158    /// Return the sum of all output tokens across agent steps.
159    pub fn total_output_tokens(&self) -> u64 {
160        self.steps.iter().filter_map(|s| s.output_tokens).sum()
161    }
162
163    /// Return the wall-clock duration since the tracker was created, in milliseconds.
164    pub fn total_duration_ms(&self) -> u64 {
165        self.start.elapsed().as_millis() as u64
166    }
167
168    /// Return the number of recorded steps (shell + agent).
169    pub fn step_count(&self) -> usize {
170        self.steps.len()
171    }
172
173    /// Emit a structured log summary of the entire workflow and each step.
174    ///
175    /// Uses [`tracing::info!`] to log one line for the workflow totals and one
176    /// line per step with its kind, duration, cost, and token counts.
177    pub fn summary(&self) {
178        let total_cost = self.total_cost_usd();
179        let total_input = self.total_input_tokens();
180        let total_output = self.total_output_tokens();
181        let total_duration = self.total_duration_ms();
182        let steps = self.step_count();
183
184        info!(
185            workflow = %self.name,
186            steps,
187            total_cost_usd = total_cost,
188            total_input_tokens = total_input,
189            total_output_tokens = total_output,
190            total_duration_ms = total_duration,
191            "workflow completed"
192        );
193
194        for step in &self.steps {
195            info!(
196                workflow = %self.name,
197                step = %step.name,
198                kind = %step.kind,
199                duration_ms = step.duration_ms,
200                cost_usd = step.cost_usd,
201                input_tokens = step.input_tokens,
202                output_tokens = step.output_tokens,
203                "step detail"
204            );
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use serde_json::json;
213
214    use crate::operations::agent::AgentResult;
215    use crate::operations::shell::Shell;
216    use crate::provider::AgentOutput;
217
218    fn make_agent_result(
219        cost: Option<f64>,
220        input_tokens: Option<u64>,
221        output_tokens: Option<u64>,
222    ) -> AgentResult {
223        let mut output = AgentOutput::new(json!("result"));
224        output.cost_usd = cost;
225        output.input_tokens = input_tokens;
226        output.output_tokens = output_tokens;
227        output.duration_ms = 100;
228        AgentResult::from_output(output)
229    }
230
231    async fn make_shell_output() -> ShellOutput {
232        Shell::new("echo test").run().await.unwrap()
233    }
234
235    #[test]
236    fn new_tracker_has_zero_steps_and_zero_cost() {
237        let tracker = WorkflowTracker::new("test");
238        assert_eq!(tracker.step_count(), 0);
239        assert_eq!(tracker.total_cost_usd(), 0.0);
240    }
241
242    #[tokio::test]
243    async fn record_shell_increments_step_count() {
244        let mut tracker = WorkflowTracker::new("test");
245        let output = make_shell_output().await;
246        tracker.record_shell("step1", &output);
247        assert_eq!(tracker.step_count(), 1);
248    }
249
250    #[test]
251    fn record_agent_with_cost_reflected_in_total() {
252        let mut tracker = WorkflowTracker::new("test");
253        let result = make_agent_result(Some(0.05), Some(100), Some(50));
254        tracker.record_agent("agent1", &result);
255        assert_eq!(tracker.total_cost_usd(), 0.05);
256    }
257
258    #[test]
259    fn record_agent_without_cost_does_not_change_total() {
260        let mut tracker = WorkflowTracker::new("test");
261        let result = make_agent_result(None, None, None);
262        tracker.record_agent("agent1", &result);
263        assert_eq!(tracker.total_cost_usd(), 0.0);
264    }
265
266    #[tokio::test]
267    async fn multiple_steps_counted_correctly() {
268        let mut tracker = WorkflowTracker::new("test");
269        let shell = make_shell_output().await;
270        let agent = make_agent_result(Some(0.1), Some(200), Some(100));
271        tracker.record_shell("s1", &shell);
272        tracker.record_agent("a1", &agent);
273        tracker.record_shell("s2", &shell);
274        assert_eq!(tracker.step_count(), 3);
275    }
276
277    #[test]
278    fn total_input_tokens_sums_across_agent_steps() {
279        let mut tracker = WorkflowTracker::new("test");
280        let r1 = make_agent_result(None, Some(100), None);
281        let r2 = make_agent_result(None, Some(250), None);
282        tracker.record_agent("a1", &r1);
283        tracker.record_agent("a2", &r2);
284        assert_eq!(tracker.total_input_tokens(), 350);
285    }
286
287    #[test]
288    fn total_output_tokens_sums_across_agent_steps() {
289        let mut tracker = WorkflowTracker::new("test");
290        let r1 = make_agent_result(None, None, Some(50));
291        let r2 = make_agent_result(None, None, Some(75));
292        tracker.record_agent("a1", &r1);
293        tracker.record_agent("a2", &r2);
294        assert_eq!(tracker.total_output_tokens(), 125);
295    }
296
297    #[test]
298    fn tokens_with_mixed_none_values() {
299        let mut tracker = WorkflowTracker::new("test");
300        let r1 = make_agent_result(None, Some(100), Some(50));
301        let r2 = make_agent_result(None, None, None);
302        let r3 = make_agent_result(None, Some(200), Some(30));
303        tracker.record_agent("a1", &r1);
304        tracker.record_agent("a2", &r2);
305        tracker.record_agent("a3", &r3);
306        assert_eq!(tracker.total_input_tokens(), 300);
307        assert_eq!(tracker.total_output_tokens(), 80);
308    }
309
310    #[test]
311    fn total_duration_ms_is_positive() {
312        let tracker = WorkflowTracker::new("test");
313        // total_duration_ms measures wall-clock time since creation, so it should be >= 0
314        // (practically > 0 due to execution time)
315        assert!(tracker.total_duration_ms() < 1000); // sanity: shouldn't take more than 1s
316    }
317
318    #[test]
319    fn summary_does_not_panic_empty() {
320        let tracker = WorkflowTracker::new("empty");
321        tracker.summary();
322    }
323
324    #[tokio::test]
325    async fn summary_does_not_panic_non_empty() {
326        let mut tracker = WorkflowTracker::new("test");
327        let shell = make_shell_output().await;
328        let agent = make_agent_result(Some(0.01), Some(10), Some(5));
329        tracker.record_shell("s1", &shell);
330        tracker.record_agent("a1", &agent);
331        tracker.summary();
332    }
333
334    #[test]
335    fn eviction_when_max_steps_exceeded() {
336        let mut tracker = WorkflowTracker::new("test").max_steps(3);
337        for i in 0..5 {
338            let r = make_agent_result(Some(i as f64), None, None);
339            tracker.record_agent(&format!("step-{i}"), &r);
340        }
341        assert_eq!(tracker.step_count(), 3);
342        // Oldest steps (0, 1) were evicted; remaining are steps 2, 3, 4
343        assert_eq!(tracker.total_cost_usd(), 2.0 + 3.0 + 4.0);
344    }
345
346    #[test]
347    fn max_steps_one_keeps_last_only() {
348        let mut tracker = WorkflowTracker::new("test").max_steps(1);
349        let r1 = make_agent_result(Some(1.0), Some(100), None);
350        let r2 = make_agent_result(Some(2.0), Some(200), None);
351        tracker.record_agent("a1", &r1);
352        tracker.record_agent("a2", &r2);
353        assert_eq!(tracker.step_count(), 1);
354        assert_eq!(tracker.total_cost_usd(), 2.0);
355        assert_eq!(tracker.total_input_tokens(), 200);
356    }
357
358    #[test]
359    fn max_steps_builder_sets_limit() {
360        let mut tracker = WorkflowTracker::new("test").max_steps(42);
361        // Verify the limit works by adding more than 42 steps
362        for i in 0..50 {
363            let r = make_agent_result(Some(1.0), None, None);
364            tracker.record_agent(&format!("step-{i}"), &r);
365        }
366        assert_eq!(tracker.step_count(), 42);
367    }
368}