1use std::collections::{BTreeMap, BTreeSet};
9
10use jiff::civil::Date;
11use jiff::Timestamp;
12use serde::Serialize;
13
14use crate::analysis::dedup::{dedup_session, DedupStats, UsageRecord};
15use crate::analysis::utc_date;
16use crate::model::{EventKind, Session};
17use crate::pricing::PricingTable;
18
19#[derive(Debug, Default, Clone, Copy)]
21pub struct Filter {
22 pub since: Option<Date>,
24}
25
26#[derive(Debug, Default, Clone, Serialize)]
28pub struct Rollup {
29 pub requests: u64,
30 pub input: u64,
31 pub output: u64,
32 pub cache_creation: u64,
33 pub cache_read: u64,
34 pub incomplete_requests: u64,
37 pub cost_usd: f64,
39 pub unpriced_requests: u64,
41}
42
43impl Rollup {
44 pub(crate) fn add(&mut self, rec: &UsageRecord, pricing: &PricingTable) {
45 self.requests += 1;
46 let u = &rec.usage;
47 self.input += u.input.unwrap_or(0);
48 self.output += u.output.unwrap_or(0);
49 self.cache_creation += u.cache_creation.unwrap_or(0);
50 self.cache_read += u.cache_read.unwrap_or(0);
51 if u.input.is_none()
52 || u.output.is_none()
53 || u.cache_creation.is_none()
54 || u.cache_read.is_none()
55 {
56 self.incomplete_requests += 1;
57 }
58 match pricing.cost_usd(rec.model.as_deref(), u) {
59 Some(cost) => self.cost_usd += cost,
60 None => self.unpriced_requests += 1,
61 }
62 }
63
64 pub fn total_tokens(&self) -> u64 {
65 self.input + self.output + self.cache_creation + self.cache_read
66 }
67}
68
69#[derive(Debug, Clone, Serialize)]
71pub struct SessionSummary {
72 pub id: String,
73 pub project: Option<String>,
74 pub model: Option<String>,
75 pub started_at: Option<Timestamp>,
76 pub rollup: Rollup,
77 pub sub_agents: u64,
80 pub sub_agent_tokens: u64,
82}
83
84#[derive(Debug, Default, Clone, Serialize)]
85pub struct ToolStat {
86 pub calls: u64,
87 pub input_bytes: u64,
88 pub server: Option<String>,
90}
91
92#[derive(Debug, Serialize)]
94pub struct Summary {
95 pub agent: String,
96 pub generated_at: Timestamp,
97 pub since: Option<Date>,
98 pub sessions_parsed: u64,
99 pub sessions_failed: u64,
100 pub skipped_lines: u64,
102 pub dedup: DedupStats,
103 pub totals: Rollup,
105 pub sidechain_totals: Rollup,
107 pub by_model: BTreeMap<String, Rollup>,
108 pub by_day: BTreeMap<String, Rollup>,
110 pub by_session: Vec<SessionSummary>,
113 pub by_tool: BTreeMap<String, ToolStat>,
114 pub unpriced_models: BTreeSet<String>,
117 pub compactions: u64,
118}
119
120#[derive(Default)]
122struct SessionAcc {
123 project: Option<String>,
124 model: Option<String>,
125 started_at: Option<Timestamp>,
126 rollup: Rollup,
127 spawn_calls: u64,
128 children_folded: u64,
129 sub_agent_tokens: u64,
130}
131
132pub fn aggregate(
134 sessions: &[Session],
135 filter: &Filter,
136 sessions_failed: u64,
137 agent: &str,
138 pricing: &PricingTable,
139) -> Summary {
140 let mut summary = Summary {
141 agent: agent.to_string(),
142 generated_at: Timestamp::now(),
143 since: filter.since,
144 sessions_parsed: sessions.len() as u64,
145 sessions_failed,
146 skipped_lines: 0,
147 dedup: DedupStats::default(),
148 totals: Rollup::default(),
149 sidechain_totals: Rollup::default(),
150 by_model: BTreeMap::new(),
151 by_day: BTreeMap::new(),
152 by_session: Vec::new(),
153 by_tool: BTreeMap::new(),
154 unpriced_models: BTreeSet::new(),
155 compactions: 0,
156 };
157 let mut rows: BTreeMap<String, SessionAcc> = BTreeMap::new();
158
159 let in_window = |ts: Option<Timestamp>| match (filter.since, ts) {
160 (None, _) => true,
161 (Some(since), Some(ts)) => utc_date(ts) >= since,
162 (Some(_), None) => false,
163 };
164
165 for session in sessions {
166 summary.skipped_lines += session.skipped_lines;
167
168 let (records, stats) = dedup_session(session, filter.since);
169 summary.dedup.duplicate_lines_collapsed += stats.duplicate_lines_collapsed;
170 summary.dedup.naive_known_tokens += stats.naive_known_tokens;
171 summary.dedup.requests_with_thinking += stats.requests_with_thinking;
172 summary.dedup.requests_with_encrypted_thinking += stats.requests_with_encrypted_thinking;
173 summary.dedup.thinking_chars_total += stats.thinking_chars_total;
174
175 for event in &session.events {
177 if !in_window(event.ts) {
178 continue;
179 }
180 if event.kind == EventKind::Compaction {
181 summary.compactions += 1;
182 }
183 for call in &event.tool_calls {
184 let stat = summary.by_tool.entry(call.name.clone()).or_default();
185 stat.calls += 1;
186 stat.input_bytes += call.input_bytes;
187 if stat.server.is_none() {
188 stat.server.clone_from(&call.server);
189 }
190 }
191 }
192
193 let row_id = session
196 .parent_session
197 .clone()
198 .unwrap_or_else(|| session.id.clone());
199 let row = rows.entry(row_id).or_default();
200 if session.parent_session.is_some() {
201 row.children_folded += 1;
202 } else {
203 row.project.clone_from(&session.project);
205 row.model.clone_from(&session.model);
206 row.started_at = session.started_at;
207 row.spawn_calls += session.sub_agents.len() as u64;
208 }
209 if row.project.is_none() {
210 row.project.clone_from(&session.project);
211 }
212
213 for rec in &records {
214 summary.totals.add(rec, pricing);
215 row.rollup.add(rec, pricing);
216 if rec.sidechain {
217 summary.sidechain_totals.add(rec, pricing);
218 row.sub_agent_tokens += rec.usage.known_total();
219 }
220 let model_key = rec.model.clone().unwrap_or_else(|| "(unknown)".into());
221 summary
222 .by_model
223 .entry(model_key)
224 .or_default()
225 .add(rec, pricing);
226 if let Some(ts) = rec.ts {
227 summary
228 .by_day
229 .entry(utc_date(ts).to_string())
230 .or_default()
231 .add(rec, pricing);
232 }
233 if let Some(model) = &rec.model {
234 if pricing.lookup(model).is_none() {
235 summary.unpriced_models.insert(model.clone());
236 }
237 }
238 }
239 }
240
241 summary.by_session = rows
242 .into_iter()
243 .filter(|(_, acc)| acc.rollup.requests > 0)
244 .map(|(id, acc)| SessionSummary {
245 id,
246 project: acc.project,
247 model: acc.model,
248 started_at: acc.started_at,
249 sub_agents: acc.spawn_calls.max(acc.children_folded),
252 sub_agent_tokens: acc.sub_agent_tokens,
253 rollup: acc.rollup,
254 })
255 .collect();
256 summary.by_session.sort_by(|a, b| {
257 b.rollup
258 .cost_usd
259 .total_cmp(&a.rollup.cost_usd)
260 .then_with(|| b.rollup.total_tokens().cmp(&a.rollup.total_tokens()))
261 .then_with(|| a.id.cmp(&b.id))
262 });
263 summary
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::model::{Event, Usage};
270
271 fn assistant_event(request_id: &str, ts: &str, input: u64) -> Event {
272 Event {
273 kind: EventKind::Assistant,
274 ts: Some(ts.parse().unwrap()),
275 request_id: Some(request_id.into()),
276 model: Some("claude-sonnet-4-5".into()),
277 usage: Some(Usage {
278 input: Some(input),
279 output: Some(10),
280 cache_creation: Some(0),
281 cache_read: Some(0),
282 ..Usage::default()
283 }),
284 tool_calls: Vec::new(),
285 sidechain: false,
286 content_summary: None,
287 content_chars: 0,
288 thinking_chars: 0,
289 has_thinking: false,
290 tool_use_id: None,
291 attachment_kind: None,
292 item_count: 0,
293 }
294 }
295
296 fn base_session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
297 Session {
298 id: id.into(),
299 agent: "claude-code".into(),
300 project: Some("proj".into()),
301 model: Some("claude-sonnet-4-5".into()),
302 parent_session: parent.map(str::to_string),
303 started_at: None,
304 ended_at: None,
305 events,
306 sub_agents: Vec::new(),
307 skipped_lines: 0,
308 }
309 }
310
311 #[test]
313 fn child_transcripts_fold_into_parent_session() {
314 let parent = base_session(
315 "parent",
316 None,
317 vec![assistant_event("req_p", "2026-06-01T10:00:00Z", 1000)],
318 );
319 let child = base_session(
320 "agent-x",
321 Some("parent"),
322 vec![assistant_event("req_c", "2026-06-01T10:01:00Z", 500)],
323 );
324 let summary = aggregate(
325 &[parent, child],
326 &Filter::default(),
327 0,
328 "claude-code",
329 &PricingTable::embedded(),
330 );
331
332 assert_eq!(summary.by_session.len(), 1, "child folded, not a row");
333 let row = &summary.by_session[0];
334 assert_eq!(row.id, "parent");
335 assert_eq!(row.rollup.input, 1500, "parent + child input");
336 assert_eq!(row.sub_agents, 1);
337 assert_eq!(summary.totals.input, 1500);
338 assert_eq!(summary.sidechain_totals.input, 500);
339 }
340
341 #[test]
342 fn since_is_inclusive_of_the_boundary_date() {
343 let s = base_session(
344 "s",
345 None,
346 vec![
347 assistant_event("req_before", "2026-06-01T23:00:00Z", 111),
348 assistant_event("req_on", "2026-06-02T00:00:00Z", 222),
349 ],
350 );
351 let filter = Filter {
352 since: Some("2026-06-02".parse().unwrap()),
353 };
354 let summary = aggregate(&[s], &filter, 0, "claude-code", &PricingTable::embedded());
355 assert_eq!(summary.totals.requests, 1);
356 assert_eq!(summary.totals.input, 222);
357 }
358}