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}