Skip to main content

warden/reports/
sessions.rs

1//! `warden report sessions` — the longest and most expensive sessions.
2//!
3//! Claude Code logs no per-turn duration, so "longest" is answered with a
4//! *span*: the wall-clock gap between a session's first and last event. That is
5//! derived from timestamps warden does have, and it is named `span` rather than
6//! `duration` so it cannot be mistaken for measured model time. The `duration`
7//! column stays next to it and greys out, which is the honest answer to "how
8//! long did the model actually work?" for a source that never wrote it down.
9
10use std::collections::BTreeMap;
11
12use crate::adapters::Kpi;
13use crate::output::{Cell, Report, Table};
14use crate::store::Scanner;
15
16use super::{
17    count, desc, format_span, rollup, scan, short_id, ReportCtx, ReportError, Totals, NO_PROJECT,
18};
19
20/// Sessions are long-tailed; the table shows the head and says so.
21const LIMIT: usize = 20;
22
23pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
24    let scanned = scan(scanner, ctx)?;
25    let by_session = rollup(&scanned.events, &ctx.pricing, |event| {
26        event.session_id.clone()
27    });
28    let total_sessions = by_session.len();
29
30    // Per-session facts that do not live on `Totals`.
31    let mut projects: BTreeMap<String, String> = BTreeMap::new();
32    let mut durations: BTreeMap<String, Option<u64>> = BTreeMap::new();
33    for event in &scanned.events {
34        let Some(session) = event.session_id.clone() else {
35            continue;
36        };
37        if let Some(project) = &event.project {
38            projects.entry(session.clone()).or_insert(project.clone());
39        }
40        if let Some(duration) = event.duration_ms {
41            *durations.entry(session).or_default().get_or_insert(0) += duration;
42        }
43    }
44
45    // Most expensive first when anything could be priced, heaviest first
46    // otherwise — ranking by a column that is entirely `–` would be a lie.
47    let priced_anywhere = by_session.values().any(|totals| totals.cost.priced > 0);
48    let mut ranked: Vec<(String, Totals)> = by_session.into_iter().collect();
49    ranked.sort_by(|a, b| {
50        let key = |t: &Totals| {
51            if priced_anywhere {
52                t.cost.total
53            } else {
54                t.total_tokens() as f64
55            }
56        };
57        desc(key(&a.1), key(&b.1))
58            .then_with(|| b.1.total_tokens().cmp(&a.1.total_tokens()))
59            .then_with(|| a.0.cmp(&b.0))
60    });
61
62    let mut table = Table::new([
63        "session",
64        "project",
65        "span",
66        "duration",
67        "requests",
68        "in",
69        "out",
70        "cache r",
71        "est. cost",
72    ]);
73    let mut rows = Vec::new();
74
75    for (session, totals) in ranked.iter().take(LIMIT) {
76        let span = span_ms(totals);
77        let duration = durations.get(session).copied().flatten();
78        let project = projects.get(session).cloned();
79
80        let mut row = vec![
81            Cell::text(short_id(session)),
82            Cell::text(project.clone().unwrap_or_else(|| NO_PROJECT.to_string())),
83            Cell::text(format_span(span)),
84            match duration {
85                Some(ms) => Cell::text(format_span(ms as i64)),
86                None => Cell::Unsupported,
87            },
88            count(totals.requests),
89        ];
90        row.extend(totals.tail_cells());
91        table.push(row);
92
93        let mut json = serde_json::Map::new();
94        json.insert("session_id".into(), serde_json::json!(session));
95        json.insert("project".into(), serde_json::json!(project));
96        json.insert("first_ts".into(), serde_json::json!(totals.first_ts));
97        json.insert("last_ts".into(), serde_json::json!(totals.last_ts));
98        json.insert("span_ms".into(), serde_json::json!(span));
99        json.insert("duration_ms".into(), serde_json::json!(duration));
100        totals.write_json(&mut json);
101        rows.push(serde_json::Value::Object(json));
102    }
103
104    let mut notes = scanned.notes;
105    notes.push(
106        "span is the wall clock between a session's first and last event, including time you spent \
107         reading; it is not measured model time",
108    );
109    if rows.iter().all(|row| row["duration_ms"].is_null()) {
110        notes.push(format!(
111            "duration is blank because no source in this period logs per-turn duration ({}); blank \
112             means unrecorded, not zero",
113            adapters_without_duration()
114        ));
115    }
116    notes.push(if priced_anywhere {
117        "ordered by est. cost, highest first"
118    } else {
119        "ordered by total tokens, highest first, because no session in this period could be priced"
120    });
121    if total_sessions > LIMIT {
122        notes.push(format!(
123            "showing the top {LIMIT} of {total_sessions} sessions, in the table and in --json alike"
124        ));
125    }
126
127    Ok(Report::new("sessions", ctx.window, table)
128        .with_json_rows(rows)
129        .with_notes(notes.finish()))
130}
131
132fn span_ms(totals: &Totals) -> i64 {
133    match (totals.first_ts, totals.last_ts) {
134        (Some(first), Some(last)) => last - first,
135        _ => 0,
136    }
137}
138
139/// Named so the note points at the adapter, not at warden.
140fn adapters_without_duration() -> String {
141    crate::adapters::registry()
142        .iter()
143        .filter(|adapter| {
144            adapter.is_implemented() && !adapter.capabilities().supports(Kpi::DurationMs)
145        })
146        .map(|adapter| adapter.name())
147        .collect::<Vec<_>>()
148        .join(", ")
149}
150
151#[cfg(test)]
152mod tests {
153    use super::super::testkit::*;
154    use super::*;
155    use crate::cli::TimeWindow;
156    use crate::output::{Style, UNSUPPORTED};
157    use crate::store::Event;
158
159    fn report(events: &[Event]) -> Report {
160        let (_dir, paths) = store(events);
161        build(
162            &Scanner::new(paths),
163            &ReportCtx::new(TimeWindow::all(), None, true),
164        )
165        .unwrap()
166    }
167
168    fn cheap_and_dear() -> Vec<Event> {
169        vec![
170            priced(used("a", ms(2026, 8, 4, 8), "acme", "m", 10, 1), 0.1),
171            priced(used("b", ms(2026, 8, 4, 10), "acme", "m", 10, 1), 0.1),
172            priced(used("c", ms(2026, 8, 4, 9), "dotfiles", "m", 5_000, 1), 9.0),
173        ]
174    }
175
176    #[test]
177    fn most_expensive_session_first() {
178        let report = report(&cheap_and_dear());
179        assert_eq!(report.json_rows[0]["session_id"], "session-dotfiles");
180        assert_eq!(report.json_rows[0]["cost_est"], 9.0);
181        assert_eq!(report.json_rows[0]["project"], "dotfiles");
182        assert!(report
183            .notes
184            .iter()
185            .any(|n| n.contains("ordered by est. cost")));
186    }
187
188    #[test]
189    fn falls_back_to_tokens_when_nothing_can_be_priced() {
190        let report = report(&[
191            used("a", ms(2026, 8, 4, 8), "acme", "m", 10, 1),
192            used("c", ms(2026, 8, 4, 9), "dotfiles", "m", 5_000, 1),
193        ]);
194        assert_eq!(report.json_rows[0]["session_id"], "session-dotfiles");
195        assert!(report
196            .notes
197            .iter()
198            .any(|n| n.contains("ordered by total tokens")));
199    }
200
201    #[test]
202    fn span_is_derived_and_unlogged_duration_stays_unsupported() {
203        let report = report(&cheap_and_dear());
204        let acme = report
205            .json_rows
206            .iter()
207            .find(|row| row["session_id"] == "session-acme")
208            .unwrap();
209        assert_eq!(acme["span_ms"], 2 * 3_600_000);
210        assert!(acme["duration_ms"].is_null());
211
212        let rendered = report.table.render(Style::plain());
213        assert!(rendered.contains("2h00m"), "{rendered}");
214        assert!(rendered.contains(UNSUPPORTED), "{rendered}");
215        assert!(report
216            .notes
217            .iter()
218            .any(|n| n.contains("blank means unrecorded, not zero")));
219        assert!(report.notes.iter().any(|n| n.contains("claude-code")));
220    }
221
222    #[test]
223    fn duration_is_shown_when_a_source_does_log_it() {
224        let mut timed = used("a", ms(2026, 8, 4, 8), "acme", "m", 10, 1);
225        timed.duration_ms = Some(8_140);
226        let report = report(&[timed]);
227        assert_eq!(report.json_rows[0]["duration_ms"], 8_140);
228        assert!(report.table.render(Style::plain()).contains("8s"));
229    }
230}