whycodes_session/
run_stats.rs1use std::collections::BTreeMap;
9
10use whycodes_core::todo::{TodoItem, TodoStatus};
11use whycodes_core::types::{ToolCall, ToolResult, Usage};
12
13use crate::session::Session;
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct ModelTally {
18 pub requests: u64,
19 pub input_tokens: u64,
20 pub output_tokens: u64,
21}
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct ToolTally {
26 pub calls: u64,
27 pub errors: u64,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RunStats {
33 pub models: BTreeMap<String, ModelTally>,
34 pub tools: BTreeMap<String, ToolTally>,
35}
36
37impl RunStats {
38 pub fn record_request(&mut self, provider: &str, model: &str, usage: &Usage) {
40 let key = format!("{provider}/{model}");
41 let tally = self.models.entry(key).or_default();
42 tally.requests += 1;
43 tally.input_tokens += usage.input_tokens;
44 tally.output_tokens += usage.output_tokens;
45 }
46
47 pub fn record_tools(&mut self, calls: &[ToolCall], results: &[ToolResult]) {
49 for call in calls {
50 let failed = results
51 .iter()
52 .any(|r| r.tool_call_id == call.id && r.is_error);
53 let tally = self.tools.entry(call.name.clone()).or_default();
54 tally.calls += 1;
55 tally.errors += u64::from(failed);
56 }
57 }
58
59 pub fn requests(&self) -> u64 {
60 self.models.values().map(|t| t.requests).sum()
61 }
62
63 pub fn tool_calls(&self) -> u64 {
64 self.tools.values().map(|t| t.calls).sum()
65 }
66
67 pub fn tool_errors(&self) -> u64 {
68 self.tools.values().map(|t| t.errors).sum()
69 }
70}
71
72const TASK_TOOL: &str = "task";
74
75pub fn stats_report(
77 session: &Session,
78 todos: &[TodoItem],
79 now: chrono::DateTime<chrono::Utc>,
80) -> String {
81 let run = &session.run_stats;
82 let mut lines = vec![
83 "Session stats".to_string(),
84 format!(
85 " duration: {} since the session started",
86 duration_label(now - session.created_at)
87 ),
88 format!(" prompts: {}", session.prompt_count()),
89 format!(" requests: {} this run", run.requests()),
90 ];
91 for (model, t) in &run.models {
92 lines.push(format!(
93 " {model}: {} · {} in / {} out",
94 plural(t.requests, "request"),
95 compact(t.input_tokens),
96 compact(t.output_tokens)
97 ));
98 }
99 lines.push(format!(
100 " tools: {} · {} failed this run",
101 plural(run.tool_calls(), "call"),
102 run.tool_errors()
103 ));
104 let mut by_use: Vec<(&String, &ToolTally)> = run.tools.iter().collect();
105 by_use.sort_by_key(|a| std::cmp::Reverse(a.1.calls));
106 if !by_use.is_empty() {
107 let parts: Vec<String> = by_use
108 .iter()
109 .map(|(name, t)| match t.errors {
110 0 => format!("{name} {}", t.calls),
111 e => format!("{name} {} ({e} failed)", t.calls),
112 })
113 .collect();
114 lines.push(format!(" {}", parts.join(" · ")));
115 }
116 let tasks = run.tools.get(TASK_TOOL).map_or(0, |t| t.calls);
117 lines.push(format!(" subagents: {tasks} launched this run"));
118 lines.push(todo_line(todos));
119 lines.push(format!(" edits: {}", session.edit_stats.label()));
120 let u = &session.usage;
121 lines.push(format!(
122 " tokens: {} in / {} out",
123 compact(u.input_tokens),
124 compact(u.output_tokens)
125 ));
126 lines.join("\n")
127}
128
129fn todo_line(todos: &[TodoItem]) -> String {
130 let count = |s: TodoStatus| todos.iter().filter(|t| t.status == s).count();
131 format!(
132 " todos: {}/{} done · {} in progress · {} pending · {} cancelled",
133 count(TodoStatus::Completed),
134 todos.len(),
135 count(TodoStatus::InProgress),
136 count(TodoStatus::Pending),
137 count(TodoStatus::Cancelled)
138 )
139}
140
141fn plural(n: u64, noun: &str) -> String {
142 match n {
143 1 => format!("1 {noun}"),
144 n => format!("{n} {noun}s"),
145 }
146}
147
148fn compact(n: u64) -> String {
150 match n {
151 n if n >= 1_000_000 => format!("{:.1}M", n as f64 / 1_000_000.0),
152 n if n >= 1_000 => format!("{:.1}k", n as f64 / 1_000.0),
153 n => n.to_string(),
154 }
155}
156
157fn duration_label(d: chrono::TimeDelta) -> String {
158 let mins = d.num_minutes().max(0);
159 match (mins / 60, mins % 60) {
160 (0, m) => format!("{m}m"),
161 (h, m) => format!("{h}h {m}m"),
162 }
163}
164
165#[cfg(test)]
166#[path = "run_stats_tests.rs"]
167mod tests;