Skip to main content

codex_ops/stats/
reports.rs

1use super::StatSort;
2use crate::pricing::TokenUsage as PricingTokenUsage;
3use crate::time::{local_to_utc, StatGroupBy};
4use chrono::{DateTime, Datelike, Local, SecondsFormat, Timelike, Utc};
5use serde::Serialize;
6
7#[derive(Debug, Clone)]
8pub struct UsageRecordsReadOptions {
9    pub start: DateTime<Utc>,
10    pub end: DateTime<Utc>,
11    pub sessions_dir: std::path::PathBuf,
12    pub scan_all_files: bool,
13    pub account_history_file: Option<std::path::PathBuf>,
14    pub account_id: Option<String>,
15}
16
17#[derive(Clone, Debug)]
18pub struct UsageRecordsReport {
19    pub start: DateTime<Utc>,
20    pub end: DateTime<Utc>,
21    pub sessions_dir: String,
22    pub records: Vec<UsageRecord>,
23    pub diagnostics: UsageDiagnostics,
24}
25
26#[derive(Clone, Debug, Default, Serialize, PartialEq)]
27#[serde(rename_all = "camelCase")]
28pub struct TokenUsage {
29    pub input_tokens: i64,
30    pub cached_input_tokens: i64,
31    pub output_tokens: i64,
32    pub reasoning_output_tokens: i64,
33    pub total_tokens: i64,
34}
35
36impl TokenUsage {
37    pub(super) fn add(&mut self, other: &TokenUsage) {
38        self.input_tokens += other.input_tokens;
39        self.cached_input_tokens += other.cached_input_tokens;
40        self.output_tokens += other.output_tokens;
41        self.reasoning_output_tokens += other.reasoning_output_tokens;
42        self.total_tokens += other.total_tokens;
43    }
44
45    pub(super) fn is_empty(&self) -> bool {
46        self.input_tokens == 0
47            && self.cached_input_tokens == 0
48            && self.output_tokens == 0
49            && self.reasoning_output_tokens == 0
50            && self.total_tokens == 0
51    }
52
53    pub(super) fn pricing_usage(&self) -> PricingTokenUsage {
54        PricingTokenUsage {
55            input_tokens: self.input_tokens.max(0) as u64,
56            cached_input_tokens: self.cached_input_tokens.max(0) as u64,
57            output_tokens: self.output_tokens.max(0) as u64,
58        }
59    }
60}
61
62#[derive(Clone, Debug)]
63pub struct UsageRecord {
64    pub timestamp: DateTime<Utc>,
65    pub session_id: String,
66    pub model: String,
67    pub reasoning_effort: Option<String>,
68    pub cwd: String,
69    pub account_id: Option<String>,
70    pub file_path: String,
71    pub usage: TokenUsage,
72}
73
74#[derive(Clone, Copy)]
75pub(super) struct UsageRecordView<'a> {
76    pub(super) timestamp: DateTime<Utc>,
77    pub(super) session_id: &'a str,
78    pub(super) model: &'a str,
79    pub(super) reasoning_effort: Option<&'a str>,
80    pub(super) cwd: &'a str,
81    pub(super) account_id: Option<&'a str>,
82    pub(super) file_path: &'a str,
83    pub(super) usage: &'a TokenUsage,
84}
85
86impl UsageRecordView<'_> {
87    pub(super) fn to_owned_record(self) -> UsageRecord {
88        UsageRecord {
89            timestamp: self.timestamp,
90            session_id: self.session_id.to_string(),
91            model: self.model.to_string(),
92            reasoning_effort: self.reasoning_effort.map(str::to_string),
93            cwd: self.cwd.to_string(),
94            account_id: self.account_id.map(str::to_string),
95            file_path: self.file_path.to_string(),
96            usage: self.usage.clone(),
97        }
98    }
99}
100
101#[derive(Clone, Debug, Serialize, PartialEq)]
102#[serde(rename_all = "camelCase")]
103pub struct UsageStatRow {
104    pub(super) key: String,
105    pub(super) sessions: usize,
106    pub(super) calls: i64,
107    pub(super) usage: TokenUsage,
108    pub(super) credits: f64,
109    pub(super) usd: f64,
110    pub(super) priced_calls: i64,
111    pub(super) unpriced_calls: i64,
112}
113
114#[derive(Clone, Debug, Serialize, PartialEq)]
115#[serde(rename_all = "camelCase")]
116pub struct UsageUnpricedModelRow {
117    pub(super) model: String,
118    pub(super) pricing_key: String,
119    pub(super) calls: i64,
120    pub(super) total_tokens: i64,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub(super) note: Option<String>,
123    pub(super) pricing_stub: String,
124}
125
126#[derive(Clone, Debug, Serialize, PartialEq)]
127#[serde(rename_all = "camelCase")]
128pub struct UsageDiagnostics {
129    pub scan_all_files: bool,
130    pub scanned_directories: i64,
131    pub skipped_directories: i64,
132    pub read_files: i64,
133    pub skipped_files: i64,
134    pub prefiltered_files: i64,
135    pub tail_read_files: i64,
136    pub tail_read_hits: i64,
137    pub mtime_read_files: i64,
138    pub mtime_tail_hits: i64,
139    pub mtime_read_hits: i64,
140    pub fork_files: i64,
141    pub fork_parent_missing: i64,
142    pub fork_replay_lines: i64,
143    pub read_lines: i64,
144    pub invalid_json_lines: i64,
145    pub token_count_events: i64,
146    pub included_usage_events: i64,
147    pub skipped_events: SkippedEvents,
148    pub file_read_concurrency: i64,
149}
150
151#[derive(Clone, Debug, Default, Serialize, PartialEq)]
152#[serde(rename_all = "camelCase")]
153pub struct SkippedEvents {
154    pub missing_metadata: i64,
155    pub missing_usage: i64,
156    pub empty_usage: i64,
157    pub out_of_range: i64,
158    pub account_mismatch: i64,
159    pub fork_replay: i64,
160}
161
162impl UsageDiagnostics {
163    pub(super) fn new(file_read_concurrency: i64, scan_all_files: bool) -> Self {
164        Self {
165            scan_all_files,
166            scanned_directories: 0,
167            skipped_directories: 0,
168            read_files: 0,
169            skipped_files: 0,
170            prefiltered_files: 0,
171            tail_read_files: 0,
172            tail_read_hits: 0,
173            mtime_read_files: 0,
174            mtime_tail_hits: 0,
175            mtime_read_hits: 0,
176            fork_files: 0,
177            fork_parent_missing: 0,
178            fork_replay_lines: 0,
179            read_lines: 0,
180            invalid_json_lines: 0,
181            token_count_events: 0,
182            included_usage_events: 0,
183            skipped_events: SkippedEvents::default(),
184            file_read_concurrency,
185        }
186    }
187
188    pub(super) fn merge_file_scan(&mut self, other: &UsageDiagnostics) {
189        self.prefiltered_files += other.prefiltered_files;
190        self.tail_read_files += other.tail_read_files;
191        self.tail_read_hits += other.tail_read_hits;
192        self.mtime_read_files += other.mtime_read_files;
193        self.mtime_tail_hits += other.mtime_tail_hits;
194        self.mtime_read_hits += other.mtime_read_hits;
195        self.fork_files += other.fork_files;
196        self.fork_parent_missing += other.fork_parent_missing;
197        self.fork_replay_lines += other.fork_replay_lines;
198        self.read_lines += other.read_lines;
199        self.invalid_json_lines += other.invalid_json_lines;
200        self.token_count_events += other.token_count_events;
201        self.included_usage_events += other.included_usage_events;
202        self.skipped_events.missing_metadata += other.skipped_events.missing_metadata;
203        self.skipped_events.missing_usage += other.skipped_events.missing_usage;
204        self.skipped_events.empty_usage += other.skipped_events.empty_usage;
205        self.skipped_events.out_of_range += other.skipped_events.out_of_range;
206        self.skipped_events.account_mismatch += other.skipped_events.account_mismatch;
207        self.skipped_events.fork_replay += other.skipped_events.fork_replay;
208    }
209}
210
211#[derive(Clone, Debug)]
212pub(super) struct UsageStatsReport {
213    pub(super) start: DateTime<Utc>,
214    pub(super) end: DateTime<Utc>,
215    pub(super) group_by: StatGroupBy,
216    pub(super) include_reasoning_effort: bool,
217    pub(super) sort_by: Option<StatSort>,
218    pub(super) limit: Option<usize>,
219    pub(super) sessions_dir: String,
220    pub(super) rows: Vec<UsageStatRow>,
221    pub(super) totals: UsageStatRow,
222    pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
223    pub(super) diagnostics: Option<UsageDiagnostics>,
224}
225
226#[derive(Clone, Debug)]
227pub(super) struct UsageSessionRow {
228    pub(super) session_id: String,
229    pub(super) model: String,
230    pub(super) cwd: String,
231    pub(super) first_seen: DateTime<Utc>,
232    pub(super) last_seen: DateTime<Utc>,
233    pub(super) calls: i64,
234    pub(super) usage: TokenUsage,
235    pub(super) credits: f64,
236    pub(super) usd: f64,
237    pub(super) priced_calls: i64,
238    pub(super) unpriced_calls: i64,
239    pub(super) file_path: String,
240}
241
242#[derive(Clone, Debug)]
243pub(super) struct UsageSessionEventRow {
244    pub(super) timestamp: DateTime<Utc>,
245    pub(super) model: String,
246    pub(super) reasoning_effort: Option<String>,
247    pub(super) cwd: String,
248    pub(super) usage: TokenUsage,
249    pub(super) credits: f64,
250    pub(super) usd: f64,
251    pub(super) priced: bool,
252    pub(super) file_path: String,
253}
254
255#[derive(Clone, Debug)]
256pub(super) struct UsageSessionCompactRow {
257    pub(super) start: DateTime<Utc>,
258    pub(super) end: DateTime<Utc>,
259    pub(super) events: usize,
260    pub(super) model: String,
261    pub(super) reasoning_effort: Option<String>,
262    pub(super) usage: TokenUsage,
263    pub(super) credits: f64,
264    pub(super) usd: f64,
265    pub(super) unpriced_calls: i64,
266}
267
268#[derive(Clone, Debug)]
269pub(super) struct UsageSessionsReport {
270    pub(super) start: DateTime<Utc>,
271    pub(super) end: DateTime<Utc>,
272    pub(super) sort_by: Option<StatSort>,
273    pub(super) limit: usize,
274    pub(super) sessions_dir: String,
275    pub(super) rows: Vec<UsageSessionRow>,
276    pub(super) totals: UsageStatRow,
277    pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
278    pub(super) diagnostics: Option<UsageDiagnostics>,
279}
280
281#[derive(Clone, Debug)]
282pub(super) struct UsageSessionDetailReport {
283    pub(super) start: DateTime<Utc>,
284    pub(super) end: DateTime<Utc>,
285    pub(super) session_id: String,
286    pub(super) limit: Option<usize>,
287    pub(super) sessions_dir: String,
288    pub(super) summary: Option<UsageSessionRow>,
289    pub(super) rows: Vec<UsageSessionEventRow>,
290    pub(super) by_model: Vec<UsageStatRow>,
291    pub(super) by_cwd: Vec<UsageStatRow>,
292    pub(super) by_reasoning_effort: Vec<UsageStatRow>,
293    pub(super) model_switches: i64,
294    pub(super) cwd_switches: i64,
295    pub(super) reasoning_effort_switches: i64,
296    pub(super) totals: UsageStatRow,
297    pub(super) unpriced_models: Vec<UsageUnpricedModelRow>,
298    pub(super) diagnostics: Option<UsageDiagnostics>,
299}
300
301#[derive(Serialize)]
302#[serde(rename_all = "camelCase")]
303pub(super) struct UsageStatsJson<'a> {
304    start: String,
305    end: String,
306    group_by: &'static str,
307    include_reasoning_effort: bool,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    sort_by: Option<&'static str>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    limit: Option<usize>,
312    sessions_dir: &'a str,
313    rows: &'a [UsageStatRow],
314    totals: &'a UsageStatRow,
315    unpriced_models: &'a [UsageUnpricedModelRow],
316    warnings: Vec<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    diagnostics: Option<&'a UsageDiagnostics>,
319}
320
321#[derive(Serialize)]
322#[serde(rename_all = "camelCase")]
323pub(super) struct UsageSessionsJson<'a> {
324    start: String,
325    end: String,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    sort_by: Option<&'static str>,
328    limit: usize,
329    sessions_dir: &'a str,
330    rows: Vec<UsageSessionRowJson<'a>>,
331    totals: &'a UsageStatRow,
332    unpriced_models: &'a [UsageUnpricedModelRow],
333    warnings: Vec<String>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    diagnostics: Option<&'a UsageDiagnostics>,
336}
337
338#[derive(Serialize)]
339#[serde(rename_all = "camelCase")]
340pub(super) struct UsageSessionRowJson<'a> {
341    session_id: &'a str,
342    model: &'a str,
343    cwd: &'a str,
344    first_seen: String,
345    last_seen: String,
346    calls: i64,
347    usage: &'a TokenUsage,
348    credits: f64,
349    usd: f64,
350    priced_calls: i64,
351    unpriced_calls: i64,
352    file_path: &'a str,
353}
354
355#[derive(Serialize)]
356#[serde(rename_all = "camelCase")]
357pub(super) struct UsageSessionDetailJson<'a> {
358    start: String,
359    end: String,
360    session_id: &'a str,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    limit: Option<usize>,
363    sessions_dir: &'a str,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    summary: Option<UsageSessionRowJson<'a>>,
366    rows: Vec<UsageSessionEventRowJson<'a>>,
367    by_model: &'a [UsageStatRow],
368    by_cwd: &'a [UsageStatRow],
369    by_reasoning_effort: &'a [UsageStatRow],
370    model_switches: i64,
371    cwd_switches: i64,
372    reasoning_effort_switches: i64,
373    totals: &'a UsageStatRow,
374    unpriced_models: &'a [UsageUnpricedModelRow],
375    warnings: Vec<String>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    diagnostics: Option<&'a UsageDiagnostics>,
378}
379
380#[derive(Serialize)]
381#[serde(rename_all = "camelCase")]
382pub(super) struct UsageSessionEventRowJson<'a> {
383    timestamp: String,
384    model: &'a str,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    reasoning_effort: Option<&'a str>,
387    cwd: &'a str,
388    usage: &'a TokenUsage,
389    credits: f64,
390    usd: f64,
391    priced: bool,
392    file_path: &'a str,
393}
394
395pub(super) fn to_usage_stats_json(report: &UsageStatsReport) -> UsageStatsJson<'_> {
396    UsageStatsJson {
397        start: iso_string(report.start),
398        end: iso_string(report.end),
399        group_by: report.group_by.as_str(),
400        include_reasoning_effort: report.include_reasoning_effort,
401        sort_by: report.sort_by.map(StatSort::as_str),
402        limit: report.limit,
403        sessions_dir: &report.sessions_dir,
404        rows: &report.rows,
405        totals: &report.totals,
406        unpriced_models: &report.unpriced_models,
407        warnings: usage_warnings(report.start, report.end, report.diagnostics.as_ref()),
408        diagnostics: report.diagnostics.as_ref(),
409    }
410}
411
412pub(super) fn to_usage_sessions_json(report: &UsageSessionsReport) -> UsageSessionsJson<'_> {
413    UsageSessionsJson {
414        start: iso_string(report.start),
415        end: iso_string(report.end),
416        sort_by: report.sort_by.map(StatSort::as_str),
417        limit: report.limit,
418        sessions_dir: &report.sessions_dir,
419        rows: report.rows.iter().map(to_session_row_json).collect(),
420        totals: &report.totals,
421        unpriced_models: &report.unpriced_models,
422        warnings: usage_warnings(report.start, report.end, report.diagnostics.as_ref()),
423        diagnostics: report.diagnostics.as_ref(),
424    }
425}
426
427pub(super) fn to_usage_session_detail_json(
428    report: &UsageSessionDetailReport,
429) -> UsageSessionDetailJson<'_> {
430    UsageSessionDetailJson {
431        start: iso_string(report.start),
432        end: iso_string(report.end),
433        session_id: &report.session_id,
434        limit: report.limit,
435        sessions_dir: &report.sessions_dir,
436        summary: report.summary.as_ref().map(to_session_row_json),
437        rows: report.rows.iter().map(to_session_event_row_json).collect(),
438        by_model: &report.by_model,
439        by_cwd: &report.by_cwd,
440        by_reasoning_effort: &report.by_reasoning_effort,
441        model_switches: report.model_switches,
442        cwd_switches: report.cwd_switches,
443        reasoning_effort_switches: report.reasoning_effort_switches,
444        totals: &report.totals,
445        unpriced_models: &report.unpriced_models,
446        warnings: usage_warnings(report.start, report.end, report.diagnostics.as_ref()),
447        diagnostics: report.diagnostics.as_ref(),
448    }
449}
450
451fn to_session_row_json(row: &UsageSessionRow) -> UsageSessionRowJson<'_> {
452    UsageSessionRowJson {
453        session_id: &row.session_id,
454        model: &row.model,
455        cwd: &row.cwd,
456        first_seen: iso_string(row.first_seen),
457        last_seen: iso_string(row.last_seen),
458        calls: row.calls,
459        usage: &row.usage,
460        credits: row.credits,
461        usd: row.usd,
462        priced_calls: row.priced_calls,
463        unpriced_calls: row.unpriced_calls,
464        file_path: &row.file_path,
465    }
466}
467
468fn to_session_event_row_json(row: &UsageSessionEventRow) -> UsageSessionEventRowJson<'_> {
469    UsageSessionEventRowJson {
470        timestamp: iso_string(row.timestamp),
471        model: &row.model,
472        reasoning_effort: row.reasoning_effort.as_deref(),
473        cwd: &row.cwd,
474        usage: &row.usage,
475        credits: row.credits,
476        usd: row.usd,
477        priced: row.priced,
478        file_path: &row.file_path,
479    }
480}
481
482pub(super) fn usage_warnings(
483    _start: DateTime<Utc>,
484    _end: DateTime<Utc>,
485    _diagnostics: Option<&UsageDiagnostics>,
486) -> Vec<String> {
487    Vec::new()
488}
489
490pub(super) fn is_all_usage_range(start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
491    start == local_to_utc(1900, 1, 1, 0, 0, 0, 0)
492        && end == local_to_utc(9999, 12, 31, 23, 59, 59, 999)
493}
494
495pub(super) fn format_report_range(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
496    if is_all_usage_range(start, end) {
497        "all".to_string()
498    } else {
499        format!("{} to {}", format_date_time(start), format_date_time(end))
500    }
501}
502
503pub(super) fn format_group_by(report: &UsageStatsReport) -> String {
504    if report.group_by == StatGroupBy::Model && report.include_reasoning_effort {
505        "model + reasoning_effort".to_string()
506    } else {
507        report.group_by.as_str().to_string()
508    }
509}
510
511pub(super) fn format_date_time(date: DateTime<Utc>) -> String {
512    let local = date.with_timezone(&Local);
513    format!(
514        "{}-{:02}-{:02} {:02}:{:02}:{:02}",
515        local.year(),
516        local.month(),
517        local.day(),
518        local.hour(),
519        local.minute(),
520        local.second()
521    )
522}
523
524fn iso_string(value: DateTime<Utc>) -> String {
525    value.to_rfc3339_opts(SecondsFormat::Millis, true)
526}