Skip to main content

lean_ctx/core/dashboard_pro/
host_usage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Host-usage pre-aggregation for `spend.trend`: what Claude Code
3//! (`~/.claude/projects/*/*.jsonl`) and Codex
4//! (`~/.codex/sessions/**/rollout-*.jsonl`) recorded about their own model
5//! use. Only token counts, model ids, times and the session folder are
6//! parsed; message content is skipped by the deserializer and never kept.
7//! Bounded: files touched in the last 30 days, at most `MAX_FILES` files and
8//! the last `MAX_FILE_BYTES` of each; only sessions in the bound project.
9
10use std::collections::{BTreeMap, HashMap};
11use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
12use std::path::{Path, PathBuf};
13use std::sync::{Mutex, OnceLock};
14
15use chrono::{DateTime, Duration, Utc};
16use serde::Deserialize;
17
18use super::wire::{HostCounts, HostUsageFile};
19
20pub(crate) const WINDOW_DAYS: i64 = 30;
21pub(crate) const MAX_FILES: usize = 2_000;
22pub(crate) const MAX_FILE_BYTES: u64 = 64 << 20;
23const MAX_DEPTH: usize = 6;
24
25#[derive(Deserialize)]
26struct Usage {
27    #[serde(default)]
28    input_tokens: Option<i64>,
29    #[serde(default)]
30    output_tokens: Option<i64>,
31    #[serde(default)]
32    cache_read_input_tokens: Option<i64>,
33    #[serde(default)]
34    cache_creation_input_tokens: Option<i64>,
35    #[serde(default)]
36    cached_input_tokens: Option<i64>,
37    #[serde(default)]
38    cache_write_input_tokens: Option<i64>,
39}
40
41#[derive(Deserialize)]
42struct ClaudeMessage {
43    #[serde(default)]
44    id: Option<String>,
45    #[serde(default)]
46    model: Option<String>,
47    #[serde(default)]
48    usage: Option<Usage>,
49}
50
51#[derive(Deserialize)]
52struct ClaudeLine {
53    #[serde(rename = "type", default)]
54    kind: Option<String>,
55    #[serde(default)]
56    timestamp: Option<String>,
57    #[serde(default)]
58    cwd: Option<String>,
59    #[serde(rename = "requestId", default)]
60    request_id: Option<String>,
61    #[serde(default)]
62    message: Option<ClaudeMessage>,
63}
64
65#[derive(Deserialize)]
66struct CodexInfo {
67    #[serde(default)]
68    last_token_usage: Option<Usage>,
69}
70
71#[derive(Deserialize)]
72struct CodexPayload {
73    #[serde(rename = "type", default)]
74    kind: Option<String>,
75    #[serde(default)]
76    cwd: Option<String>,
77    #[serde(default)]
78    model: Option<String>,
79    #[serde(default)]
80    info: Option<CodexInfo>,
81}
82
83#[derive(Deserialize)]
84struct CodexLine {
85    #[serde(rename = "type", default)]
86    kind: Option<String>,
87    #[serde(default)]
88    timestamp: Option<String>,
89    #[serde(default)]
90    payload: Option<CodexPayload>,
91}
92
93/// One session file's counts; the folder stays in memory for matching only.
94#[derive(Default, Clone)]
95struct FileUsage {
96    host: &'static str,
97    offset: u64,
98    cwd: String,
99    model: String,
100    last_id: String,
101    days: BTreeMap<String, BTreeMap<String, HostCounts>>,
102    /// The same counts per UTC minute (`YYYY-MM-DDTHH:MM`) and model, for
103    /// usage within a time window ([`usage_between`]); sparse.
104    minutes: BTreeMap<String, BTreeMap<String, HostCounts>>,
105}
106
107fn cache() -> &'static Mutex<HashMap<PathBuf, FileUsage>> {
108    static C: OnceLock<Mutex<HashMap<PathBuf, FileUsage>>> = OnceLock::new();
109    C.get_or_init(|| Mutex::new(HashMap::new()))
110}
111
112fn modified_after(path: &Path, cutoff: std::time::SystemTime) -> Option<u64> {
113    let meta = std::fs::symlink_metadata(path).ok()?;
114    (meta.file_type().is_file() && meta.modified().ok()? >= cutoff).then_some(meta.len())
115}
116
117fn sources(home: &Path, cutoff: std::time::SystemTime) -> Vec<(&'static str, PathBuf, u64)> {
118    let mut found = Vec::new();
119    if let Ok(projects) = std::fs::read_dir(home.join(".claude").join("projects")) {
120        for dir in projects.filter_map(Result::ok) {
121            if !dir.file_type().is_ok_and(|t| t.is_dir()) {
122                continue;
123            }
124            let Ok(files) = std::fs::read_dir(dir.path()) else {
125                continue;
126            };
127            for file in files.filter_map(Result::ok) {
128                let path = file.path();
129                if path.extension().is_some_and(|e| e == "jsonl")
130                    && let Some(len) = modified_after(&path, cutoff)
131                {
132                    found.push(("claude-code", path, len));
133                }
134            }
135        }
136    }
137    let mut stack = vec![(home.join(".codex").join("sessions"), 0usize)];
138    while let Some((dir, depth)) = stack.pop() {
139        let Ok(entries) = std::fs::read_dir(&dir) else {
140            continue;
141        };
142        for entry in entries.filter_map(Result::ok) {
143            let Ok(kind) = entry.file_type() else {
144                continue;
145            };
146            let path = entry.path();
147            if kind.is_dir() && depth < MAX_DEPTH {
148                stack.push((path, depth + 1));
149            } else if kind.is_file()
150                && entry.file_name().to_string_lossy().starts_with("rollout-")
151                && path.extension().is_some_and(|e| e == "jsonl")
152                && let Some(len) = modified_after(&path, cutoff)
153            {
154                found.push(("codex", path, len));
155            }
156        }
157    }
158    found.sort_by_key(|a| std::cmp::Reverse(a.2));
159    found.truncate(MAX_FILES);
160    found
161}
162
163/// Add one request's `counts` to its day and (with a valid stamp) minute.
164fn add(usage: &mut FileUsage, stamp: &str, model: &str, now: DateTime<Utc>, counts: &HostCounts) {
165    let add_to = |row: &mut HostCounts| {
166        row.requests += counts.requests;
167        row.input += counts.input;
168        row.cache_read += counts.cache_read;
169        row.cache_write += counts.cache_write;
170        row.output += counts.output;
171    };
172    let valid_day = stamp.get(..10).filter(|d| super::request::is_day(d));
173    let day = valid_day.map_or_else(|| now.date_naive().to_string(), str::to_string);
174    let model: String = if model.is_empty() { "unknown" } else { model }
175        .chars()
176        .take(80)
177        .collect();
178    add_to(
179        usage
180            .days
181            .entry(day)
182            .or_default()
183            .entry(model.clone())
184            .or_default(),
185    );
186    if let Some(minute) = valid_day
187        .and(stamp.get(..16))
188        .filter(|m| m.as_bytes().get(10) == Some(&b'T'))
189    {
190        add_to(
191            usage
192                .minutes
193                .entry(minute.to_owned())
194                .or_default()
195                .entry(model)
196                .or_default(),
197        );
198    }
199}
200
201fn claude_line(usage: &mut FileUsage, line: &[u8], now: DateTime<Utc>) {
202    let marker = |m: &[u8]| line.windows(m.len()).any(|w| w == m);
203    if !marker(b"\"usage\"") || !marker(b"\"assistant\"") {
204        return;
205    }
206    let Ok(record) = serde_json::from_slice::<ClaudeLine>(line) else {
207        return;
208    };
209    if record.kind.as_deref() != Some("assistant") {
210        return;
211    }
212    let Some(message) = record.message else {
213        return;
214    };
215    let Some(counts) = message.usage else { return };
216    let model = message.model.unwrap_or_default();
217    let id = message.id.or(record.request_id).unwrap_or_default();
218    if model == "<synthetic>" || (!id.is_empty() && id == usage.last_id) {
219        return;
220    }
221    usage.last_id = id;
222    if let Some(cwd) = record.cwd {
223        usage.cwd = cwd;
224    }
225    let counts = HostCounts {
226        requests: 1,
227        input: counts.input_tokens.unwrap_or(0),
228        cache_read: counts.cache_read_input_tokens.unwrap_or(0),
229        cache_write: counts.cache_creation_input_tokens.unwrap_or(0),
230        output: counts.output_tokens.unwrap_or(0),
231    };
232    add(
233        usage,
234        record.timestamp.as_deref().unwrap_or(""),
235        &model,
236        now,
237        &counts,
238    );
239}
240
241fn codex_line(usage: &mut FileUsage, line: &[u8], now: DateTime<Utc>) {
242    let marker = |m: &[u8]| line.windows(m.len()).any(|w| w == m);
243    if !marker(b"\"token_count\"") && !marker(b"\"turn_context\"") && !marker(b"\"session_meta\"") {
244        return;
245    }
246    let Ok(record) = serde_json::from_slice::<CodexLine>(line) else {
247        return;
248    };
249    let Some(payload) = record.payload else {
250        return;
251    };
252    match record.kind.as_deref() {
253        Some("session_meta") => {
254            if let Some(cwd) = payload.cwd {
255                usage.cwd = cwd;
256            }
257        }
258        Some("turn_context") => {
259            if let Some(model) = payload.model {
260                usage.model = model.chars().take(80).collect();
261            }
262            if let Some(cwd) = payload.cwd {
263                usage.cwd = cwd;
264            }
265        }
266        Some("event_msg") if payload.kind.as_deref() == Some("token_count") => {
267            let Some(counts) = payload.info.and_then(|i| i.last_token_usage) else {
268                return;
269            };
270            let model = usage.model.clone();
271            let cached = counts.cached_input_tokens.unwrap_or(0);
272            let counts = HostCounts {
273                requests: 1,
274                input: (counts.input_tokens.unwrap_or(0) - cached).max(0),
275                cache_read: cached,
276                cache_write: counts.cache_write_input_tokens.unwrap_or(0),
277                output: counts.output_tokens.unwrap_or(0),
278            };
279            add(
280                usage,
281                record.timestamp.as_deref().unwrap_or(""),
282                &model,
283                now,
284                &counts,
285            );
286        }
287        _ => {}
288    }
289}
290
291fn read_new(path: &Path, len: u64, usage: &mut FileUsage, now: DateTime<Utc>) {
292    let Ok(mut file) = std::fs::File::open(path) else {
293        return;
294    };
295    // A file larger than the cap is read from its last MAX_FILE_BYTES only.
296    let mut start = usage.offset.max(len.saturating_sub(MAX_FILE_BYTES));
297    if file.seek(SeekFrom::Start(start)).is_err() {
298        return;
299    }
300    let mut reader = BufReader::new(file.take(len - start));
301    let mut line = Vec::new();
302    if start > usage.offset {
303        // Skip the partial line the cut landed in.
304        let Ok(read) = reader.read_until(b'\n', &mut line) else {
305            return;
306        };
307        start += read as u64;
308    }
309    usage.offset = start;
310    loop {
311        line.clear();
312        let Ok(read) = reader.read_until(b'\n', &mut line) else {
313            break;
314        };
315        if read == 0 || line.last() != Some(&b'\n') {
316            break;
317        }
318        usage.offset += read as u64;
319        if usage.host == "codex" {
320            codex_line(usage, &line, now);
321        } else {
322            claude_line(usage, &line, now);
323        }
324    }
325}
326
327fn under(folder: &str, root: &str) -> bool {
328    let folder = folder.trim_end_matches('/');
329    let root = root.trim_end_matches('/');
330    !root.is_empty() && (folder == root || folder.starts_with(&format!("{root}/")))
331}
332
333/// Input `host_usage`: per session file of the bound project, daily tokens
334/// per model within the last 30 days. `project` is the project root (the
335/// folder the runtime matches by its `repo_hash`).
336pub(crate) fn collect(home: &Path, roots: &[String], now: DateTime<Utc>) -> Vec<HostUsageFile> {
337    scan(home, now, WINDOW_DAYS, |cwd| {
338        roots.iter().any(|r| under(cwd, r)).then_some(0)
339    })
340    .into_iter()
341    .map(|(_, mut file)| {
342        // Every matching session is reported under the project's own root.
343        file.project.clone_from(&roots[0]);
344        file
345    })
346    .collect()
347}
348
349/// Every session file of the last `window_days` whose folder lies under one
350/// of `roots`, each assigned to its deepest matching root (an index into
351/// `roots`), so nested project roots never count one session twice. The
352/// returned `project` is empty: callers name projects by their own ids.
353pub(crate) fn collect_assigned(
354    home: &Path,
355    roots: &[String],
356    now: DateTime<Utc>,
357    window_days: i64,
358) -> Vec<(usize, HostUsageFile)> {
359    scan(home, now, window_days.max(1), |cwd| {
360        roots
361            .iter()
362            .enumerate()
363            .filter(|(_, root)| under(cwd, root))
364            .max_by_key(|(_, root)| root.trim_end_matches('/').len())
365            .map(|(index, _)| index)
366    })
367}
368
369/// Per model, what the session files under `roots` recorded from the
370/// minute of `from` up to the minute of `to` (both included), from files
371/// touched in the last `WINDOW_DAYS`. A session under a deeper root of
372/// `others` (another project nested inside) belongs to that one only.
373pub fn usage_between(
374    home: &Path,
375    roots: &[String],
376    others: &[String],
377    now: DateTime<Utc>,
378    from: DateTime<Utc>,
379    to: DateTime<Utc>,
380) -> BTreeMap<String, HostCounts> {
381    let minute = |t: DateTime<Utc>| t.format("%Y-%m-%dT%H:%M").to_string();
382    let (first, last) = (minute(from), minute(to));
383    let mut out: BTreeMap<String, HostCounts> = BTreeMap::new();
384    if first > last {
385        return out;
386    }
387    visit(home, now, WINDOW_DAYS, |usage| {
388        let depth = |list: &[String]| {
389            list.iter()
390                .filter(|root| under(&usage.cwd, root))
391                .map(|root| root.trim_end_matches('/').len())
392                .max()
393        };
394        let Some(own) = depth(roots) else {
395            return;
396        };
397        if depth(others).is_some_and(|other| other > own) {
398            return;
399        }
400        for models in usage
401            .minutes
402            .range(first.clone()..=last.clone())
403            .map(|(_, models)| models)
404        {
405            for (model, counts) in models {
406                let row = out.entry(model.clone()).or_default();
407                row.requests += counts.requests;
408                row.input += counts.input;
409                row.cache_read += counts.cache_read;
410                row.cache_write += counts.cache_write;
411                row.output += counts.output;
412            }
413        }
414    });
415    out
416}
417
418/// Bring the cached counts of every session file touched in the last
419/// `window_days` up to date and hand each to `each`.
420fn visit(home: &Path, now: DateTime<Utc>, window_days: i64, mut each: impl FnMut(&FileUsage)) {
421    let cutoff_time = std::time::SystemTime::now()
422        - std::time::Duration::from_secs(u64::try_from(window_days).unwrap_or(30) * 86_400);
423    let found = sources(home, cutoff_time);
424    let mut map = cache()
425        .lock()
426        .unwrap_or_else(std::sync::PoisonError::into_inner);
427    let live: std::collections::HashSet<&PathBuf> = found.iter().map(|(_, p, _)| p).collect();
428    map.retain(|path, _| live.contains(path));
429    for (host, path, len) in &found {
430        let usage = map.entry(path.clone()).or_insert_with(|| FileUsage {
431            host,
432            ..FileUsage::default()
433        });
434        if *len < usage.offset {
435            *usage = FileUsage {
436                host,
437                ..FileUsage::default()
438            };
439        }
440        if *len > usage.offset {
441            read_new(path, *len, usage, now);
442        }
443        each(usage);
444    }
445}
446
447fn scan(
448    home: &Path,
449    now: DateTime<Utc>,
450    window_days: i64,
451    assign: impl Fn(&str) -> Option<usize>,
452) -> Vec<(usize, HostUsageFile)> {
453    let cutoff_day = (now - Duration::days(window_days - 1))
454        .date_naive()
455        .to_string();
456    let mut out = Vec::new();
457    visit(home, now, window_days, |usage| {
458        let Some(assigned) = assign(&usage.cwd) else {
459            return;
460        };
461        let days: BTreeMap<String, BTreeMap<String, HostCounts>> = usage
462            .days
463            .iter()
464            .filter(|(day, _)| **day >= cutoff_day)
465            .map(|(d, m)| (d.clone(), m.clone()))
466            .collect();
467        if days.is_empty() {
468            return;
469        }
470        out.push((
471            assigned,
472            HostUsageFile {
473                project: String::new(),
474                host: usage.host.to_string(),
475                days,
476            },
477        ));
478    });
479    out
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn line(stamp: &str, id: &str, input: i64, output: i64, cwd: &str) -> String {
487        serde_json::json!({
488            "type": "assistant", "timestamp": stamp, "cwd": cwd, "requestId": id,
489            "message": {"id": id, "model": "claude-opus-5-5",
490                "usage": {"input_tokens": input, "output_tokens": output}}
491        })
492        .to_string()
493            + "\n"
494    }
495
496    /// Only the requests inside the window, of sessions under the roots,
497    /// count; the day totals stay as they were.
498    #[test]
499    fn usage_between_counts_the_requests_inside_the_window() {
500        let home = tempfile::tempdir().unwrap();
501        let dir = home.path().join(".claude/projects/p");
502        std::fs::create_dir_all(&dir).unwrap();
503        let now = Utc::now();
504        let minute = |m: i64| (now - Duration::minutes(m)).to_rfc3339();
505        let log = [
506            line(&minute(90), "r1", 100, 10, "/work/payments"),
507            line(&minute(30), "r2", 200, 20, "/work/payments"),
508            line(&minute(10), "r3", 400, 40, "/work/payments/src"),
509        ]
510        .concat();
511        std::fs::write(dir.join("s.jsonl"), log).unwrap();
512        std::fs::write(
513            dir.join("other.jsonl"),
514            line(&minute(30), "o1", 9_000, 900, "/work/other"),
515        )
516        .unwrap();
517        let roots = vec!["/work/payments".to_owned()];
518        let counts = usage_between(
519            home.path(),
520            &roots,
521            &[],
522            now,
523            now - Duration::minutes(45),
524            now,
525        );
526        let row = &counts["claude-opus-5-5"];
527        assert_eq!((row.requests, row.input, row.output), (2, 600, 60));
528        let all = collect_assigned(home.path(), &roots, now, 2);
529        let total: i64 = all
530            .iter()
531            .flat_map(|(_, file)| file.days.values())
532            .flat_map(BTreeMap::values)
533            .map(|c| c.input)
534            .sum();
535        assert_eq!(total, 700, "the day buckets keep every request");
536        assert!(
537            usage_between(
538                home.path(),
539                &roots,
540                &[],
541                now,
542                now,
543                now - Duration::minutes(1)
544            )
545            .is_empty()
546        );
547
548        // A project nested inside another owns its sessions alone.
549        std::fs::write(
550            dir.join("nested.jsonl"),
551            line(&minute(20), "n1", 50, 5, "/work/payments/billing"),
552        )
553        .unwrap();
554        let nested = vec!["/work/payments/billing".to_owned()];
555        let window = (now - Duration::minutes(45), now);
556        let outer = usage_between(home.path(), &roots, &nested, now, window.0, window.1);
557        assert_eq!(outer["claude-opus-5-5"].input, 600);
558        let inner = usage_between(home.path(), &nested, &roots, now, window.0, window.1);
559        assert_eq!(inner["claude-opus-5-5"].input, 50);
560    }
561}