1use 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
32const DEFAULT_MAX_STEPS: usize = 10_000;
35
36pub 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 #[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 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 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 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 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 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 pub fn total_cost_usd(&self) -> f64 {
198 self.steps.iter().filter_map(|s| s.cost_usd).sum()
199 }
200
201 pub fn total_input_tokens(&self) -> u64 {
203 self.steps.iter().filter_map(|s| s.input_tokens).sum()
204 }
205
206 pub fn total_output_tokens(&self) -> u64 {
208 self.steps.iter().filter_map(|s| s.output_tokens).sum()
209 }
210
211 pub fn total_duration_ms(&self) -> u64 {
213 self.start.elapsed().as_millis() as u64
214 }
215
216 pub fn step_count(&self) -> usize {
218 self.steps.len()
219 }
220
221 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 assert!(tracker.total_duration_ms() < 1000); }
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 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 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}