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;
30use crate::pricing::CostBreakdown;
31
32/// Default maximum number of steps kept in the tracker.
33/// Older steps are evicted when this limit is reached.
34const DEFAULT_MAX_STEPS: usize = 10_000;
35
36/// Aggregates cost, token, and duration metrics for a named workflow.
37///
38/// Create one tracker per workflow run with [`WorkflowTracker::new`], record
39/// each step with [`record_shell`](WorkflowTracker::record_shell) or
40/// [`record_agent`](WorkflowTracker::record_agent), then call
41/// [`summary`](WorkflowTracker::summary) to emit a structured log line.
42pub struct WorkflowTracker {
43    name: String,
44    start: Instant,
45    steps: VecDeque<StepRecord>,
46    max_steps: usize,
47}
48
49struct StepRecord {
50    name: String,
51    kind: StepKind,
52    duration_ms: u64,
53    cost_usd: Option<f64>,
54    input_tokens: Option<u64>,
55    output_tokens: Option<u64>,
56    cost_breakdown: Option<CostBreakdown>,
57}
58
59enum StepKind {
60    Shell,
61    Http,
62    Agent,
63}
64
65impl fmt::Display for StepKind {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Shell => f.write_str("shell"),
69            Self::Http => f.write_str("http"),
70            Self::Agent => f.write_str("agent"),
71        }
72    }
73}
74
75impl WorkflowTracker {
76    /// Create a new tracker for a workflow with the given `name`.
77    ///
78    /// The wall-clock timer starts immediately.
79    #[must_use = "a tracker does nothing if not used to record steps"]
80    pub fn new(name: &str) -> Self {
81        Self {
82            name: name.to_string(),
83            start: Instant::now(),
84            steps: VecDeque::new(),
85            max_steps: DEFAULT_MAX_STEPS,
86        }
87    }
88
89    /// Set the maximum number of steps to retain.
90    ///
91    /// When exceeded, the oldest step is removed. Defaults to 10 000.
92    pub fn max_steps(mut self, limit: usize) -> Self {
93        self.max_steps = limit;
94        self
95    }
96
97    fn push_step(&mut self, record: StepRecord) {
98        if self.steps.len() >= self.max_steps {
99            self.steps.pop_front();
100        }
101        self.steps.push_back(record);
102    }
103
104    /// Record a completed shell step.
105    ///
106    /// Extracts the duration from the [`ShellOutput`]. Shell steps have no
107    /// associated cost or token counts.
108    pub fn record_shell(&mut self, name: &str, output: &ShellOutput) {
109        self.push_step(StepRecord {
110            name: name.to_string(),
111            kind: StepKind::Shell,
112            duration_ms: output.duration_ms(),
113            cost_usd: None,
114            input_tokens: None,
115            output_tokens: None,
116            cost_breakdown: None,
117        });
118    }
119
120    /// Record a completed HTTP step.
121    ///
122    /// Extracts the duration from the [`HttpOutput`]. HTTP steps have no
123    /// associated cost or token counts.
124    pub fn record_http(&mut self, name: &str, output: &HttpOutput) {
125        self.push_step(StepRecord {
126            name: name.to_string(),
127            kind: StepKind::Http,
128            duration_ms: output.duration_ms(),
129            cost_usd: None,
130            input_tokens: None,
131            output_tokens: None,
132            cost_breakdown: None,
133        });
134    }
135
136    /// Record a completed agent step.
137    ///
138    /// Extracts duration, cost, and token counts from the [`AgentResult`].
139    pub fn record_agent(&mut self, name: &str, result: &AgentResult) {
140        self.push_step(StepRecord {
141            name: name.to_string(),
142            kind: StepKind::Agent,
143            duration_ms: result.duration_ms(),
144            cost_usd: result.cost_usd(),
145            input_tokens: result.input_tokens(),
146            output_tokens: result.output_tokens(),
147            cost_breakdown: None,
148        });
149    }
150
151    /// Record a completed agent step with a detailed cost breakdown.
152    ///
153    /// Like [`record_agent`](Self::record_agent) but also stores a
154    /// [`CostBreakdown`] with the prompt/completion split. The breakdown
155    /// is included in the [`summary`](Self::summary) log output.
156    ///
157    /// # Examples
158    ///
159    /// ```no_run
160    /// use ironflow_core::tracker::WorkflowTracker;
161    /// use ironflow_core::pricing::{CostBreakdown, StaticPricing};
162    /// use ironflow_core::operations::agent::AgentResult;
163    ///
164    /// # fn example(result: &AgentResult) {
165    /// let pricing = StaticPricing::new();
166    /// let bd = CostBreakdown::compute(
167    ///     &pricing,
168    ///     result.model().unwrap_or("sonnet"),
169    ///     result.input_tokens().unwrap_or(0),
170    ///     result.output_tokens().unwrap_or(0),
171    /// );
172    ///
173    /// let mut tracker = WorkflowTracker::new("my-workflow");
174    /// tracker.record_agent_with_breakdown("review", result, bd);
175    /// # }
176    /// ```
177    pub fn record_agent_with_breakdown(
178        &mut self,
179        name: &str,
180        result: &AgentResult,
181        breakdown: CostBreakdown,
182    ) {
183        self.push_step(StepRecord {
184            name: name.to_string(),
185            kind: StepKind::Agent,
186            duration_ms: result.duration_ms(),
187            cost_usd: result.cost_usd(),
188            input_tokens: result.input_tokens(),
189            output_tokens: result.output_tokens(),
190            cost_breakdown: Some(breakdown),
191        });
192    }
193
194    /// Return the sum of all agent step costs in USD.
195    ///
196    /// Steps that did not report a cost (including all shell steps) are skipped.
197    pub fn total_cost_usd(&self) -> f64 {
198        self.steps.iter().filter_map(|s| s.cost_usd).sum()
199    }
200
201    /// Return the sum of all input tokens across agent steps.
202    pub fn total_input_tokens(&self) -> u64 {
203        self.steps.iter().filter_map(|s| s.input_tokens).sum()
204    }
205
206    /// Return the sum of all output tokens across agent steps.
207    pub fn total_output_tokens(&self) -> u64 {
208        self.steps.iter().filter_map(|s| s.output_tokens).sum()
209    }
210
211    /// Return the wall-clock duration since the tracker was created, in milliseconds.
212    pub fn total_duration_ms(&self) -> u64 {
213        self.start.elapsed().as_millis() as u64
214    }
215
216    /// Return the number of recorded steps (shell + agent).
217    pub fn step_count(&self) -> usize {
218        self.steps.len()
219    }
220
221    /// Emit a structured log summary of the entire workflow and each step.
222    ///
223    /// Uses [`tracing::info!`] to log one line for the workflow totals and one
224    /// line per step with its kind, duration, cost, and token counts.
225    pub fn summary(&self) {
226        let total_cost = self.total_cost_usd();
227        let total_input = self.total_input_tokens();
228        let total_output = self.total_output_tokens();
229        let total_duration = self.total_duration_ms();
230        let steps = self.step_count();
231
232        info!(
233            workflow = %self.name,
234            steps,
235            total_cost_usd = total_cost,
236            total_input_tokens = total_input,
237            total_output_tokens = total_output,
238            total_duration_ms = total_duration,
239            "workflow completed"
240        );
241
242        for step in &self.steps {
243            if let Some(ref bd) = step.cost_breakdown {
244                info!(
245                    workflow = %self.name,
246                    step = %step.name,
247                    kind = %step.kind,
248                    duration_ms = step.duration_ms,
249                    cost_usd = step.cost_usd,
250                    prompt_usd = bd.prompt_usd,
251                    cache_read_usd = bd.cache_read_usd,
252                    cache_write_usd = bd.cache_write_usd,
253                    completion_usd = bd.completion_usd,
254                    input_tokens = step.input_tokens,
255                    output_tokens = step.output_tokens,
256                    "step detail"
257                );
258            } else {
259                info!(
260                    workflow = %self.name,
261                    step = %step.name,
262                    kind = %step.kind,
263                    duration_ms = step.duration_ms,
264                    cost_usd = step.cost_usd,
265                    input_tokens = step.input_tokens,
266                    output_tokens = step.output_tokens,
267                    "step detail"
268                );
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use serde_json::json;
278
279    use crate::operations::agent::AgentResult;
280    use crate::operations::shell::Shell;
281    use crate::provider::AgentOutput;
282
283    fn make_agent_result(
284        cost: Option<f64>,
285        input_tokens: Option<u64>,
286        output_tokens: Option<u64>,
287    ) -> AgentResult {
288        let mut output = AgentOutput::new(json!("result"));
289        output.cost_usd = cost;
290        output.input_tokens = input_tokens;
291        output.output_tokens = output_tokens;
292        output.duration_ms = 100;
293        AgentResult::from_output(output)
294    }
295
296    async fn make_shell_output() -> ShellOutput {
297        Shell::new("echo test").run().await.unwrap()
298    }
299
300    #[test]
301    fn new_tracker_has_zero_steps_and_zero_cost() {
302        let tracker = WorkflowTracker::new("test");
303        assert_eq!(tracker.step_count(), 0);
304        assert_eq!(tracker.total_cost_usd(), 0.0);
305    }
306
307    #[tokio::test]
308    async fn record_shell_increments_step_count() {
309        let mut tracker = WorkflowTracker::new("test");
310        let output = make_shell_output().await;
311        tracker.record_shell("step1", &output);
312        assert_eq!(tracker.step_count(), 1);
313    }
314
315    #[test]
316    fn record_agent_with_cost_reflected_in_total() {
317        let mut tracker = WorkflowTracker::new("test");
318        let result = make_agent_result(Some(0.05), Some(100), Some(50));
319        tracker.record_agent("agent1", &result);
320        assert_eq!(tracker.total_cost_usd(), 0.05);
321    }
322
323    #[test]
324    fn record_agent_without_cost_does_not_change_total() {
325        let mut tracker = WorkflowTracker::new("test");
326        let result = make_agent_result(None, None, None);
327        tracker.record_agent("agent1", &result);
328        assert_eq!(tracker.total_cost_usd(), 0.0);
329    }
330
331    #[tokio::test]
332    async fn multiple_steps_counted_correctly() {
333        let mut tracker = WorkflowTracker::new("test");
334        let shell = make_shell_output().await;
335        let agent = make_agent_result(Some(0.1), Some(200), Some(100));
336        tracker.record_shell("s1", &shell);
337        tracker.record_agent("a1", &agent);
338        tracker.record_shell("s2", &shell);
339        assert_eq!(tracker.step_count(), 3);
340    }
341
342    #[test]
343    fn total_input_tokens_sums_across_agent_steps() {
344        let mut tracker = WorkflowTracker::new("test");
345        let r1 = make_agent_result(None, Some(100), None);
346        let r2 = make_agent_result(None, Some(250), None);
347        tracker.record_agent("a1", &r1);
348        tracker.record_agent("a2", &r2);
349        assert_eq!(tracker.total_input_tokens(), 350);
350    }
351
352    #[test]
353    fn total_output_tokens_sums_across_agent_steps() {
354        let mut tracker = WorkflowTracker::new("test");
355        let r1 = make_agent_result(None, None, Some(50));
356        let r2 = make_agent_result(None, None, Some(75));
357        tracker.record_agent("a1", &r1);
358        tracker.record_agent("a2", &r2);
359        assert_eq!(tracker.total_output_tokens(), 125);
360    }
361
362    #[test]
363    fn tokens_with_mixed_none_values() {
364        let mut tracker = WorkflowTracker::new("test");
365        let r1 = make_agent_result(None, Some(100), Some(50));
366        let r2 = make_agent_result(None, None, None);
367        let r3 = make_agent_result(None, Some(200), Some(30));
368        tracker.record_agent("a1", &r1);
369        tracker.record_agent("a2", &r2);
370        tracker.record_agent("a3", &r3);
371        assert_eq!(tracker.total_input_tokens(), 300);
372        assert_eq!(tracker.total_output_tokens(), 80);
373    }
374
375    #[test]
376    fn total_duration_ms_is_positive() {
377        let tracker = WorkflowTracker::new("test");
378        // total_duration_ms measures wall-clock time since creation, so it should be >= 0
379        // (practically > 0 due to execution time)
380        assert!(tracker.total_duration_ms() < 1000); // sanity: shouldn't take more than 1s
381    }
382
383    #[test]
384    fn summary_does_not_panic_empty() {
385        let tracker = WorkflowTracker::new("empty");
386        tracker.summary();
387    }
388
389    #[tokio::test]
390    async fn summary_does_not_panic_non_empty() {
391        let mut tracker = WorkflowTracker::new("test");
392        let shell = make_shell_output().await;
393        let agent = make_agent_result(Some(0.01), Some(10), Some(5));
394        tracker.record_shell("s1", &shell);
395        tracker.record_agent("a1", &agent);
396        tracker.summary();
397    }
398
399    #[test]
400    fn eviction_when_max_steps_exceeded() {
401        let mut tracker = WorkflowTracker::new("test").max_steps(3);
402        for i in 0..5 {
403            let r = make_agent_result(Some(i as f64), None, None);
404            tracker.record_agent(&format!("step-{i}"), &r);
405        }
406        assert_eq!(tracker.step_count(), 3);
407        // Oldest steps (0, 1) were evicted; remaining are steps 2, 3, 4
408        assert_eq!(tracker.total_cost_usd(), 2.0 + 3.0 + 4.0);
409    }
410
411    #[test]
412    fn max_steps_one_keeps_last_only() {
413        let mut tracker = WorkflowTracker::new("test").max_steps(1);
414        let r1 = make_agent_result(Some(1.0), Some(100), None);
415        let r2 = make_agent_result(Some(2.0), Some(200), None);
416        tracker.record_agent("a1", &r1);
417        tracker.record_agent("a2", &r2);
418        assert_eq!(tracker.step_count(), 1);
419        assert_eq!(tracker.total_cost_usd(), 2.0);
420        assert_eq!(tracker.total_input_tokens(), 200);
421    }
422
423    #[test]
424    fn max_steps_builder_sets_limit() {
425        let mut tracker = WorkflowTracker::new("test").max_steps(42);
426        // Verify the limit works by adding more than 42 steps
427        for i in 0..50 {
428            let r = make_agent_result(Some(1.0), None, None);
429            tracker.record_agent(&format!("step-{i}"), &r);
430        }
431        assert_eq!(tracker.step_count(), 42);
432    }
433
434    #[test]
435    fn record_agent_with_breakdown_stores_cost_split() {
436        let mut tracker = WorkflowTracker::new("test");
437        let result = make_agent_result(Some(0.05), Some(1000), Some(500));
438        let bd = CostBreakdown {
439            prompt_usd: 0.003,
440            cache_read_usd: 0.0,
441            cache_write_usd: 0.0,
442            completion_usd: 0.047,
443            total_usd: 0.05,
444        };
445        tracker.record_agent_with_breakdown("a1", &result, bd);
446        assert_eq!(tracker.step_count(), 1);
447        assert_eq!(tracker.total_cost_usd(), 0.05);
448    }
449
450    #[test]
451    fn summary_does_not_panic_with_breakdown() {
452        let mut tracker = WorkflowTracker::new("test");
453        let result = make_agent_result(Some(0.01), Some(100), Some(50));
454        let bd = CostBreakdown {
455            prompt_usd: 0.003,
456            cache_read_usd: 0.0,
457            cache_write_usd: 0.0,
458            completion_usd: 0.007,
459            total_usd: 0.01,
460        };
461        tracker.record_agent_with_breakdown("a1", &result, bd);
462        tracker.summary();
463    }
464}