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                    completion_usd = bd.completion_usd,
252                    input_tokens = step.input_tokens,
253                    output_tokens = step.output_tokens,
254                    "step detail"
255                );
256            } else {
257                info!(
258                    workflow = %self.name,
259                    step = %step.name,
260                    kind = %step.kind,
261                    duration_ms = step.duration_ms,
262                    cost_usd = step.cost_usd,
263                    input_tokens = step.input_tokens,
264                    output_tokens = step.output_tokens,
265                    "step detail"
266                );
267            }
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use serde_json::json;
276
277    use crate::operations::agent::AgentResult;
278    use crate::operations::shell::Shell;
279    use crate::provider::AgentOutput;
280
281    fn make_agent_result(
282        cost: Option<f64>,
283        input_tokens: Option<u64>,
284        output_tokens: Option<u64>,
285    ) -> AgentResult {
286        let mut output = AgentOutput::new(json!("result"));
287        output.cost_usd = cost;
288        output.input_tokens = input_tokens;
289        output.output_tokens = output_tokens;
290        output.duration_ms = 100;
291        AgentResult::from_output(output)
292    }
293
294    async fn make_shell_output() -> ShellOutput {
295        Shell::new("echo test").run().await.unwrap()
296    }
297
298    #[test]
299    fn new_tracker_has_zero_steps_and_zero_cost() {
300        let tracker = WorkflowTracker::new("test");
301        assert_eq!(tracker.step_count(), 0);
302        assert_eq!(tracker.total_cost_usd(), 0.0);
303    }
304
305    #[tokio::test]
306    async fn record_shell_increments_step_count() {
307        let mut tracker = WorkflowTracker::new("test");
308        let output = make_shell_output().await;
309        tracker.record_shell("step1", &output);
310        assert_eq!(tracker.step_count(), 1);
311    }
312
313    #[test]
314    fn record_agent_with_cost_reflected_in_total() {
315        let mut tracker = WorkflowTracker::new("test");
316        let result = make_agent_result(Some(0.05), Some(100), Some(50));
317        tracker.record_agent("agent1", &result);
318        assert_eq!(tracker.total_cost_usd(), 0.05);
319    }
320
321    #[test]
322    fn record_agent_without_cost_does_not_change_total() {
323        let mut tracker = WorkflowTracker::new("test");
324        let result = make_agent_result(None, None, None);
325        tracker.record_agent("agent1", &result);
326        assert_eq!(tracker.total_cost_usd(), 0.0);
327    }
328
329    #[tokio::test]
330    async fn multiple_steps_counted_correctly() {
331        let mut tracker = WorkflowTracker::new("test");
332        let shell = make_shell_output().await;
333        let agent = make_agent_result(Some(0.1), Some(200), Some(100));
334        tracker.record_shell("s1", &shell);
335        tracker.record_agent("a1", &agent);
336        tracker.record_shell("s2", &shell);
337        assert_eq!(tracker.step_count(), 3);
338    }
339
340    #[test]
341    fn total_input_tokens_sums_across_agent_steps() {
342        let mut tracker = WorkflowTracker::new("test");
343        let r1 = make_agent_result(None, Some(100), None);
344        let r2 = make_agent_result(None, Some(250), None);
345        tracker.record_agent("a1", &r1);
346        tracker.record_agent("a2", &r2);
347        assert_eq!(tracker.total_input_tokens(), 350);
348    }
349
350    #[test]
351    fn total_output_tokens_sums_across_agent_steps() {
352        let mut tracker = WorkflowTracker::new("test");
353        let r1 = make_agent_result(None, None, Some(50));
354        let r2 = make_agent_result(None, None, Some(75));
355        tracker.record_agent("a1", &r1);
356        tracker.record_agent("a2", &r2);
357        assert_eq!(tracker.total_output_tokens(), 125);
358    }
359
360    #[test]
361    fn tokens_with_mixed_none_values() {
362        let mut tracker = WorkflowTracker::new("test");
363        let r1 = make_agent_result(None, Some(100), Some(50));
364        let r2 = make_agent_result(None, None, None);
365        let r3 = make_agent_result(None, Some(200), Some(30));
366        tracker.record_agent("a1", &r1);
367        tracker.record_agent("a2", &r2);
368        tracker.record_agent("a3", &r3);
369        assert_eq!(tracker.total_input_tokens(), 300);
370        assert_eq!(tracker.total_output_tokens(), 80);
371    }
372
373    #[test]
374    fn total_duration_ms_is_positive() {
375        let tracker = WorkflowTracker::new("test");
376        // total_duration_ms measures wall-clock time since creation, so it should be >= 0
377        // (practically > 0 due to execution time)
378        assert!(tracker.total_duration_ms() < 1000); // sanity: shouldn't take more than 1s
379    }
380
381    #[test]
382    fn summary_does_not_panic_empty() {
383        let tracker = WorkflowTracker::new("empty");
384        tracker.summary();
385    }
386
387    #[tokio::test]
388    async fn summary_does_not_panic_non_empty() {
389        let mut tracker = WorkflowTracker::new("test");
390        let shell = make_shell_output().await;
391        let agent = make_agent_result(Some(0.01), Some(10), Some(5));
392        tracker.record_shell("s1", &shell);
393        tracker.record_agent("a1", &agent);
394        tracker.summary();
395    }
396
397    #[test]
398    fn eviction_when_max_steps_exceeded() {
399        let mut tracker = WorkflowTracker::new("test").max_steps(3);
400        for i in 0..5 {
401            let r = make_agent_result(Some(i as f64), None, None);
402            tracker.record_agent(&format!("step-{i}"), &r);
403        }
404        assert_eq!(tracker.step_count(), 3);
405        // Oldest steps (0, 1) were evicted; remaining are steps 2, 3, 4
406        assert_eq!(tracker.total_cost_usd(), 2.0 + 3.0 + 4.0);
407    }
408
409    #[test]
410    fn max_steps_one_keeps_last_only() {
411        let mut tracker = WorkflowTracker::new("test").max_steps(1);
412        let r1 = make_agent_result(Some(1.0), Some(100), None);
413        let r2 = make_agent_result(Some(2.0), Some(200), None);
414        tracker.record_agent("a1", &r1);
415        tracker.record_agent("a2", &r2);
416        assert_eq!(tracker.step_count(), 1);
417        assert_eq!(tracker.total_cost_usd(), 2.0);
418        assert_eq!(tracker.total_input_tokens(), 200);
419    }
420
421    #[test]
422    fn max_steps_builder_sets_limit() {
423        let mut tracker = WorkflowTracker::new("test").max_steps(42);
424        // Verify the limit works by adding more than 42 steps
425        for i in 0..50 {
426            let r = make_agent_result(Some(1.0), None, None);
427            tracker.record_agent(&format!("step-{i}"), &r);
428        }
429        assert_eq!(tracker.step_count(), 42);
430    }
431
432    #[test]
433    fn record_agent_with_breakdown_stores_cost_split() {
434        let mut tracker = WorkflowTracker::new("test");
435        let result = make_agent_result(Some(0.05), Some(1000), Some(500));
436        let bd = CostBreakdown {
437            prompt_usd: 0.003,
438            completion_usd: 0.047,
439            total_usd: 0.05,
440        };
441        tracker.record_agent_with_breakdown("a1", &result, bd);
442        assert_eq!(tracker.step_count(), 1);
443        assert_eq!(tracker.total_cost_usd(), 0.05);
444    }
445
446    #[test]
447    fn summary_does_not_panic_with_breakdown() {
448        let mut tracker = WorkflowTracker::new("test");
449        let result = make_agent_result(Some(0.01), Some(100), Some(50));
450        let bd = CostBreakdown {
451            prompt_usd: 0.003,
452            completion_usd: 0.007,
453            total_usd: 0.01,
454        };
455        tracker.record_agent_with_breakdown("a1", &result, bd);
456        tracker.summary();
457    }
458}