Skip to content

perf(adapter): unify JSONL prefilter and byte_lines across agents - #1326

Merged
ryoppippi merged 2 commits into
mainfrom
perf-unify-jsonl-prefilter
Jun 15, 2026
Merged

ryoppippi merged 2 commits into
mainfrom
perf-unify-jsonl-prefilter

Conversation

@ryoppippi

@ryoppippi ryoppippi commented Jun 15, 2026 •

Copy link
Copy Markdown
Member

Summary

Unifies the JSONL parsing fast-path across agent adapters. The claude and codex adapters already read file bytes, iterate newline-delimited slices via byte_lines, and use a reusable memmem substring prefilter to skip non-usage lines before running serde_json. This PR brings the remaining line-delimited adapters — pi, kimi, qwen, openclaw, copilot — onto the same path.

A new shared LinePrefilter helper in fast.rs builds the memmem::Finder needles once (owned, reused across every line) instead of allocating a fresh searcher on each str::contains call. It supports both "contains all markers" and "contains any marker" modes to match the existing per-adapter filter semantics.

What changed

Adapter Before After
pi read_to_string().lines() + str::contains fs::read + byte_lines + LinePrefilter::all
kimi read_to_string().lines() + str::contains fs::read + byte_lines + LinePrefilter::all
qwen BufReader::lines(), no prefilter fs::read + byte_lines + LinePrefilter::all([usageMetadata])
openclaw BufReader::lines() + str::contains fs::read + byte_lines + LinePrefilter::any
copilot read_to_string().lines() + str::contains fs::read + byte_lines + LinePrefilter::all

Parsing logic is otherwise unchanged (still serde_json::Value-based).

Behavioral notes

  • pi, kimi, openclaw, copilot keep their exact existing marker substrings; only the scanning mechanism changes.
  • qwen previously parsed every line; it now skips lines lacking "usageMetadata", which is a required field for any emitted entry (record.get("usageMetadata")?), so the result set is unchanged — it just gains a prefilter it didn't have.
  • Reading bytes instead of a UTF-8 string makes a single invalid line fail to parse and be skipped rather than aborting the whole file — matching the existing claude/codex behavior.

Scope notes

  • codex is left as-is: it already uses memmem and streams with BufReader::read_until; converting it to whole-file byte_lines could regress very large session logs.
  • gemini's JSONL path carries cross-line state (session/model set by marker-less lines), so a token-based prefilter would break attribution — excluded.
  • amp, codebuff, droid, goose, hermes, kilo, opencode read single JSON documents or SQLite, not JSONL — out of scope.
  • Migrating these adapters from serde_json::Value to fully typed structs is a larger, higher-risk follow-up and is intentionally not included here.

Validation

  • cargo test -p ccusage: 271 passed (incl. new LinePrefilter tests and all adapter loader-level tests).
  • cargo clippy --all-targets: clean.
  • treefmt / just fmt: clean.
  • JSON parity vs ccusage@latest (v20.0.13): ran <agent> daily --json --offline --until 20260614 for every agent with local logs and confirmed byte-identical output:
    • migrated adapters with data: pi (4 rows), copilot (1 row) ✅
    • shared fast.rs hot path: claude (155 rows), codex (110 rows) ✅
    • also identical: gemini, opencode, amp
    • kimi / qwen / openclaw had no local logs (skipped).

View with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is enabled.


Summary by cubic

Unifies the JSONL fast path across agents with byte_lines, a reusable LinePrefilter, and a shared prefiltered_json_values helper to skip non-usage lines before serde_json. Migrates pi, kimi, qwen, openclaw, and copilot to the same path as claude/codex, improving scan speed and resilience to invalid lines with no output changes.

  • Refactors
    • Added LinePrefilter and prefiltered_json_values in fast.rs (all/any markers) built on memchr::memmem::Finder.
    • Switched the listed adapters to fs::read + prefiltered_json_values with the same marker substrings as before.
    • qwen: now prefilters on "usageMetadata"; emitted results stay the same.
    • Parsing bytes now skips bad lines instead of aborting the file; codex and gemini paths remain unchanged.

Written for commit 519a249. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Performance
    • Improved usage-log ingestion speed across multiple providers by switching to byte-oriented JSONL prefiltering, reducing unnecessary parsing work and improving throughput on large files.
    • Updated several provider parsers (including Copilot, Kimi, OpenClaw, Pi, and Qwen) to use the same more efficient filtering approach.
  • Tests
    • Expanded coverage for the new log prefiltering behavior to ensure filtered and malformed records are handled consistently.

Migrate the pi, kimi, qwen, openclaw, and copilot adapters to the same
fast line-scanning path the claude and codex adapters already use:
read the file as bytes, iterate newline-delimited slices with
`byte_lines`, and skip non-usage lines with a reusable `memmem`
substring prefilter before paying for a full `serde_json` parse.

Add a shared `LinePrefilter` helper to `fast.rs` so the per-line skip
check is built once (owned `Finder` needles) and reused across every
line via the SIMD-accelerated `memmem` path, instead of allocating a
fresh searcher on each `str::contains` call. The helper supports both
"contains all markers" and "contains any marker" modes to cover the
existing per-adapter filter semantics.

Behavioral notes:
- pi, kimi, openclaw, copilot keep their exact existing marker
  substrings; only the scanning mechanism changes.
- qwen previously parsed every line; it now skips lines without
  `"usageMetadata"`, which is a required field for any emitted entry,
  so the result set is unchanged.
- Reading bytes instead of a UTF-8 string makes a single invalid line
  fail to parse and be skipped rather than aborting the whole file,
  matching the claude/codex behavior.

Verified byte-identical `daily --json --offline` output against
ccusage@latest for every agent with local logs (pi, copilot, claude,
codex, gemini, opencode, amp), with logs capped at a fixed date.
@pullfrog

pullfrog Bot commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

no API key found — this repo is configured to use deepseek/deepseek-v4-pro, which needs DEEPSEEK_API_KEY, but the runner has no key for it.

To fix: add the key as a GitHub Actions secret (referenced from your workflow's env: block) or as a Pullfrog secret in the console — or switch this repo to a different model (free models need no key).

Open repo secrets → · Configure model → · Setup docs → · Ask in Discord →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Jun 15, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7c12fd28-b175-49d8-97c7-b81e35aa6f6c

📥 Commits

Reviewing files that changed from the base of the PR and between 56dceb0 and 519a249.

📒 Files selected for processing (6)
  • rust/crates/ccusage/src/adapter/copilot/parser.rs
  • rust/crates/ccusage/src/adapter/kimi/parser.rs
  • rust/crates/ccusage/src/adapter/openclaw/parser.rs
  • rust/crates/ccusage/src/adapter/pi/parser.rs
  • rust/crates/ccusage/src/adapter/qwen/parser.rs
  • rust/crates/ccusage/src/fast.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • rust/crates/ccusage/src/fast.rs
  • rust/crates/ccusage/src/adapter/copilot/parser.rs
  • rust/crates/ccusage/src/adapter/openclaw/parser.rs
  • rust/crates/ccusage/src/adapter/qwen/parser.rs

📝 Walkthrough

Walkthrough

fast.rs gains a new LinePrefilter type that precomputes memmem::Finder needles for efficient byte-pattern matching, along with a prefiltered_json_values helper function that filters newline-delimited byte lines and parses matching lines into serde_json::Value. Five adapter parsers (copilot, kimi, openclaw, pi, qwen) are then uniformly migrated from fs::read_to_string + string lines() + contains + from_str to fs::read + byte_lines + LinePrefilter + from_slice.

Changes

Byte-oriented LinePrefilter rollout

Layer / File(s) Summary
LinePrefilter type, prefiltered_json_values helper, and tests
rust/crates/ccusage/src/fast.rs
Adds PrefilterMode enum and LinePrefilter struct with all and any constructors and a matches method backed by precomputed memmem::Finder instances. Introduces prefiltered_json_values(content, prefilter) function that iterates over newline-delimited byte lines, filters lines via prefilter.matches, parses matching lines to owned serde_json::Values, and silently drops malformed JSON. Tests validate all-mode (every marker required), any-mode (at least one marker), prefiltered iteration (skips non-matching lines), and malformed JSON handling.
Adapter parsers migrated to byte pipeline
rust/crates/ccusage/src/adapter/copilot/parser.rs, rust/crates/ccusage/src/adapter/kimi/parser.rs, rust/crates/ccusage/src/adapter/openclaw/parser.rs, rust/crates/ccusage/src/adapter/pi/parser.rs, rust/crates/ccusage/src/adapter/qwen/parser.rs
Each adapter's main file-reading function is rewritten to use fs::read instead of read_to_string, constructs a LinePrefilter::all with adapter-specific byte markers ("attributes", "StatusUpdate" + "token_usage", "model_change" + "model-snapshot" + "usage", "usage" + "message", usageMetadata), iterates via prefiltered_json_values(content, prefilter), and parses matching lines with serde_json::from_slice. Import blocks are reorganized to pull LinePrefilter and prefiltered_json_values from the fast module.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐇 Hop, hop through bytes so neat,
No more strings to read and greet!
memmem needles find each line,
Five parsers march in perfect time.
From slice to JSON, swift and bright —
The rabbit's code review takes flight! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main objective: unifying JSONL prefilter and byte_lines parsing across five agent adapters (pi, kimi, qwen, openclaw, copilot). It is concise, specific, and clearly conveys the primary change.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf-unify-jsonl-prefilter

Comment @coderabbitai help to get the list of available commands and usage tips.

@ryoppippi

Copy link
Copy Markdown
Member Author

@coderabbitai review

This PR unifies the JSONL parsing fast-path (byte_lines + a shared memmem-based LinePrefilter) across the pi, kimi, qwen, openclaw, and copilot adapters. Parsing logic is unchanged; only the line-scanning mechanism and prefilter were refactored. Please review for correctness and any parity concerns. Thanks!

The pi, kimi, qwen, openclaw, and copilot parsers all repeated the same
three-step boilerplate after the previous commit: iterate `byte_lines`,
skip lines rejected by the `LinePrefilter`, and `serde_json::from_slice`
each surviving line into a `Value`.

Move that loop into a single `prefiltered_json_values` helper in
`fast.rs` that yields the parsed `Value`s, and have every line-delimited
adapter consume it. This removes the duplicated scan/parse glue and
leaves each adapter with only its own per-record logic.

No behavior change: the helper performs the identical filter-then-parse
sequence and drops unparseable lines exactly as the inline `let Ok(..)
else { continue }` did.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Re-trigger cubic

@ryoppippi

Copy link
Copy Markdown
Member Author

@coderabbitai review

Pushed a follow-up refactor (519a249b): the five migrated adapters were repeating the same byte_lines + LinePrefilter + from_slice boilerplate, so I hoisted that scan loop into a single prefiltered_json_values helper in fast.rs. Each adapter now consumes the shared iterator and keeps only its own per-record logic. No behavior change — verified byte-identical daily --json --offline output vs ccusage@latest again for every agent with local logs. Please re-review.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 15, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
ccusage-guide 519a249 Commit Preview URL

Branch Preview URL
Jun 15 2026, 03:30 PM

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the PR details and files changed
gh pr view 1326 --repo ccusage/ccusage --json headRefName,baseRefName,files | jq .

# Read fast.rs
cat rust/crates/ccusage/src/fast.rs

echo "=== copilot parser ==="
cat rust/crates/ccusage/src/adapter/copilot/parser.rs

echo "=== kimi parser ==="
cat rust/crates/ccusage/src/adapter/kimi/parser.rs

echo "=== openclaw parser ==="
cat rust/crates/ccusage/src/adapter/openclaw/parser.rs

echo "=== pi parser ==="
cat rust/crates/ccusage/src/adapter/pi/parser.rs

echo "=== qwen parser ==="
cat rust/crates/ccusage/src/adapter/qwen/parser.rs

Length of output: 66099



@ryoppippi The follow-up refactor looks great. The prefiltered_json_values helper is clean and well-designed — here's a full pass:


fast.rs — prefiltered_json_values

The signature is correct:

pub(crate) fn prefiltered_json_values<'a>(
    content: &'a [u8],
    prefilter: &'a LinePrefilter,
) -> impl Iterator<Item = Value> + 'a

Tying both borrows to 'a is slightly conservative (the prefilter reference only needs to live through the iterator's filter step), but in practice callers always create both in the same scope, so this is never a problem.

The move in .filter(move |line| prefilter.matches(line)) correctly captures the &'a LinePrefilter reference (which is Copy), so the closure doesn't touch ownership of the prefilter value itself.


Per-adapter review

Adapter Mode Notes
copilot all([b"\"attributes\""]) Correctly collects into Vec<Map<..>> for the two-pass trace-context algorithm — no way around this, and it's clear why
kimi all([b"\"StatusUpdate\"", b"\"token_usage\""]) Single-pass .filter_map(...).collect() — minimal and clean
openclaw any([b"\"model_change\"", b"\"model-snapshot\"", b"\"usage\""]) The value.as_object() guard in the loop correctly handles any non-object Value that sneaks through; any semantics match the original "at least one of" filter
pi all([b"\"usage\"", b"\"message\""]) Retains the is_pi_message_usage post-filter for the role/type checks that the prefilter can't express — correct layering
qwen all([b"\"usageMetadata\""]) The newly added prefilter is safe: parse_line always checks for usageMetadata anyway, so no valid records are dropped

Correctness of the marker byte strings

All markers use the quoted form (e.g. br#""usage""# = the bytes "usage" with surrounding quotes), so there are no false positives from fields like "usageFoo" or "usageMetadata" triggering the "usage" marker.


Test coverage

The new prefiltered_json_values_skips_filtered_and_malformed_lines test in fast.rs covers the key cases: prefilter rejection, JSON parse failure (silent drop), and successful emission. The existing line_prefilter_all/any tests cover the two modes. Good shape.


Overall: the abstraction is well-placed, the boilerplate is gone, each adapter retains only its unique logic, and no behavioral regressions are visible. LGTM. 🐇

[approve]

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== fast.rs ==="
cat rust/crates/ccusage/src/fast.rs

echo ""
echo "=== copilot parser ==="
cat rust/crates/ccusage/src/adapter/copilot/parser.rs

echo ""
echo "=== kimi parser ==="
cat rust/crates/ccusage/src/adapter/kimi/parser.rs

echo ""
echo "=== openclaw parser ==="
cat rust/crates/ccusage/src/adapter/openclaw/parser.rs

echo ""
echo "=== pi parser ==="
cat rust/crates/ccusage/src/adapter/pi/parser.rs

echo ""
echo "=== qwen parser ==="
cat rust/crates/ccusage/src/adapter/qwen/parser.rs

Length of output: 65046



@ryoppippi Thanks for the clear description! Here's a full review of the initial PR:


fast.rs — LinePrefilter and prefiltered_json_values

LinePrefilter is well-structured. The Finder::new(marker).into_owned() call correctly promotes the needle to a 'static lifetime, allowing the LinePrefilter to be stored and reused without borrowing the caller's marker slice. The All/Any dispatch in matches is straightforward and correct.

prefiltered_json_values composes correctly: byte_lines → prefilter → from_slice with silent drop on parse failure. The 'a bound tying both content and prefilter lifetimes together is slightly conservative (the prefilter reference only needs to survive the filter step, not the full iterator), but callers always create both in the same scope so this is never observable.

The move in .filter(move |line| prefilter.matches(line)) captures prefilter: &'a LinePrefilter which is Copy — correct.


Marker correctness

All markers use the quoted form (e.g. br#""usage""# = the 7 bytes "usage" including surrounding quotes). This means:

  • "usage" does not match "usageMetadata" — the byte after e in usageMetadata is M, not ".
  • "usage" does not match "totalUsage" — the opening " lands before t, not before u.

No false positives from field name aliasing.


Per-adapter review

Adapter Mode Notes
copilot all([b"\"attributes\""]) Correctly collects into Vec<Map<..>> — the two-pass collect_trace_contexts → to_candidate algorithm requires all records upfront. No alternative.
kimi all([b"\"StatusUpdate\"", b"\"token_usage\""]) Single-pass .filter_map(wire_line_to_entry).collect() — minimal and clean.
openclaw any([b"\"model_change\"", b"\"model-snapshot\"", b"\"usage\""]) any correctly matches the original "at least one of" semantics. The value.as_object() guard in the loop handles any non-object Value that sneaks through.
pi all([b"\"usage\"", b"\"message\""]) Retains the is_pi_message_usage post-filter for role/type checks the prefilter can't express — correct layering.
qwen all([b"\"usageMetadata\""]) Safe new addition: parse_line re-checks usageMetadata anyway, so no valid records are dropped.

Error-handling improvement

Switching from BufReader + reader.lines() to fs::read + byte_lines + from_slice().ok() aligns pi, kimi, qwen, openclaw, and copilot with the claude/codex behavior: a single malformed or non-UTF-8 line is silently skipped rather than aborting the entire file. This is strictly better.


Test coverage

The new prefiltered_json_values_skips_filtered_and_malformed_lines test in fast.rs covers the key cases: prefilter rejection, JSON parse failure (silent drop), and successful emission. The line_prefilter_all/line_prefilter_any tests cover both modes. Each adapter has existing unit tests. Good shape overall.


Overall: the implementation is correct, the marker strings are safe, each adapter retains its exact behavioral semantics, and the error handling is uniformly improved. LGTM. 🐇

[approve]

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@ccusage/ccusage-darwin-arm64

npx https://pkg.pr.new/@ccusage/ccusage-darwin-arm64@1326

@ccusage/ccusage-darwin-x64

npx https://pkg.pr.new/@ccusage/ccusage-darwin-x64@1326

@ccusage/ccusage-linux-arm64

npx https://pkg.pr.new/@ccusage/ccusage-linux-arm64@1326

@ccusage/ccusage-linux-x64

npx https://pkg.pr.new/@ccusage/ccusage-linux-x64@1326

@ccusage/ccusage-win32-x64

npx https://pkg.pr.new/@ccusage/ccusage-win32-x64@1326

ccusage

npx https://pkg.pr.new/ccusage@1326

commit: 519a249

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: 56dceb077862
Base SHA: cd5d58fcaacf

This compares the PR package against the configured base package on the same CI runner.

Package runtime diagnostics

Compares the PR package wrapper, the installed native optional dependency binary, and the workspace release binary on the same large fixture. This identifies whether slow package results come from JavaScript wrapper overhead, the published native binary build, or the Rust core itself.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
All rows run --offline --json, measured by hyperfine with 0 warmups and 1 runs. This isolates wrapper overhead from the installed native optional dependency and the workspace release binary built on the runner.

Command Runtime Input Median Throughput Samples
claude --offline --json Package wrapper 1.01 GiB 232.7ms 4.33 GiB/s 1
claude --offline --json Installed native binary 1.01 GiB 212.1ms 4.75 GiB/s 1
codex --offline --json Package wrapper 1.01 GiB 112.1ms 8.98 GiB/s 1
codex --offline --json Installed native binary 1.01 GiB 85.9ms 11.72 GiB/s 1

Committed fixture performance

Committed small fixtures for stable PR-to-PR feedback and explicit Claude/Codex command coverage.

Fixtures: Claude apps/ccusage/test/fixtures/claude (0.00 MiB, 2 files), Codex apps/ccusage/test/fixtures/codex (0.00 MiB, 1 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published ccusage package from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 2 warmups and 7 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude daily --offline --json 0.00 MiB 28.5ms 28.7ms 0.99x 53.75 MiB 54.00 MiB 1.00x 0.05 MiB/s 0.05 MiB/s
claude session --offline --json 0.00 MiB 30.3ms 28.3ms 1.07x 54.00 MiB 53.75 MiB 1.00x 0.05 MiB/s 0.05 MiB/s
codex daily --offline --json 0.00 MiB 27.4ms 26.0ms 1.06x 53.75 MiB 53.75 MiB 1.00x 0.03 MiB/s 0.03 MiB/s
codex session --offline --json 0.00 MiB 28.5ms 28.2ms 1.01x 54.00 MiB 53.75 MiB 1.00x 0.03 MiB/s 0.03 MiB/s

Large real-world-shaped fixture performance

Generated fixtures shaped from aggregate local log statistics: thousands of JSONL files, many small sessions, and a long tail of larger sessions. No real prompts, paths, or outputs are stored in the fixtures.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published ccusage package from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 0 warmups and 1 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude --offline --json 1.01 GiB 294.7ms 266.8ms 1.10x 942.33 MiB 932.33 MiB 0.99x 3.42 GiB/s 3.77 GiB/s
codex --offline --json 1.01 GiB 109.2ms 126.1ms 0.87x 413.03 MiB 405.29 MiB 0.98x 9.22 GiB/s 7.98 GiB/s

Artifact size

Artifact Base PR Delta Ratio
packed ccusage-*.tgz 18.08 KiB 18.08 KiB -0.00 KiB 1.00x
installed native package binary 3851.00 KiB 3858.50 KiB +7.50 KiB 1.00x

Lower medians and smaller artifacts are better. CI runner noise still applies; use same-run ratios as directional PR feedback, not release guarantees.

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: 56dceb077862
Base SHA: cd5d58fcaacf

This compares the Rust PR release binary against the configured base package on the same CI runner.

Package runtime diagnostics

Compares the PR package wrapper, the installed native optional dependency binary, and the workspace release binary on the same large fixture. This identifies whether slow package results come from JavaScript wrapper overhead, the published native binary build, or the Rust core itself.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
All rows run --offline --json, measured by hyperfine with 0 warmups and 1 runs. This isolates wrapper overhead from the installed native optional dependency and the workspace release binary built on the runner.

Command Runtime Input Median Throughput Samples
claude --offline --json Package wrapper 1.01 GiB 263.9ms 3.81 GiB/s 1
claude --offline --json Installed native binary 1.01 GiB 225.7ms 4.46 GiB/s 1
codex --offline --json Package wrapper 1.01 GiB 111.7ms 9.02 GiB/s 1
codex --offline --json Installed native binary 1.01 GiB 92.5ms 10.89 GiB/s 1

Committed fixture performance

Committed small fixtures for stable PR-to-PR feedback and explicit Claude/Codex command coverage.

Fixtures: Claude apps/ccusage/test/fixtures/claude (0.00 MiB, 2 files), Codex apps/ccusage/test/fixtures/codex (0.00 MiB, 1 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published native ccusage binary from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 2 warmups and 7 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude daily --offline --json 0.00 MiB 30.5ms 4.8ms 6.38x 53.75 MiB 10.21 MiB 0.19x 0.05 MiB/s 0.32 MiB/s
claude session --offline --json 0.00 MiB 33.1ms 4.0ms 8.30x 54.25 MiB 10.20 MiB 0.19x 0.05 MiB/s 0.39 MiB/s
codex daily --offline --json 0.00 MiB 28.9ms 3.1ms 9.22x 53.75 MiB 8.19 MiB 0.15x 0.03 MiB/s 0.27 MiB/s
codex session --offline --json 0.00 MiB 28.5ms 2.7ms 10.44x 53.75 MiB 8.19 MiB 0.15x 0.03 MiB/s 0.31 MiB/s

Large real-world-shaped fixture performance

Generated fixtures shaped from aggregate local log statistics: thousands of JSONL files, many small sessions, and a long tail of larger sessions. No real prompts, paths, or outputs are stored in the fixtures.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published native ccusage binary from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 0 warmups and 1 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude --offline --json 1.01 GiB 277.4ms 247.2ms 1.12x 948.32 MiB 964.33 MiB 1.02x 3.63 GiB/s 4.07 GiB/s
codex --offline --json 1.01 GiB 154.5ms 95.2ms 1.62x 411.02 MiB 425.29 MiB 1.03x 6.51 GiB/s 10.57 GiB/s

Artifact size

Artifact Base PR Delta Ratio
packed ccusage-*.tgz 18.08 KiB 18.08 KiB -0.00 KiB 1.00x
installed native package binary 3851.00 KiB 3858.50 KiB +7.50 KiB 1.00x

Lower medians and smaller artifacts are better. CI runner noise still applies; use same-run ratios as directional PR feedback, not release guarantees.

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: 519a249b988f
Base SHA: cd5d58fcaacf

This compares the Rust PR release binary against the configured base package on the same CI runner.

Package runtime diagnostics

Compares the PR package wrapper, the installed native optional dependency binary, and the workspace release binary on the same large fixture. This identifies whether slow package results come from JavaScript wrapper overhead, the published native binary build, or the Rust core itself.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
All rows run --offline --json, measured by hyperfine with 0 warmups and 1 runs. This isolates wrapper overhead from the installed native optional dependency and the workspace release binary built on the runner.

Command Runtime Input Median Throughput Samples
claude --offline --json Package wrapper 1.01 GiB 247.4ms 4.07 GiB/s 1
claude --offline --json Installed native binary 1.01 GiB 223.8ms 4.50 GiB/s 1
codex --offline --json Package wrapper 1.01 GiB 113.7ms 8.86 GiB/s 1
codex --offline --json Installed native binary 1.01 GiB 113.8ms 8.85 GiB/s 1

Committed fixture performance

Committed small fixtures for stable PR-to-PR feedback and explicit Claude/Codex command coverage.

Fixtures: Claude apps/ccusage/test/fixtures/claude (0.00 MiB, 2 files), Codex apps/ccusage/test/fixtures/codex (0.00 MiB, 1 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published native ccusage binary from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 2 warmups and 7 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude daily --offline --json 0.00 MiB 33.7ms 5.4ms 6.27x 53.75 MiB 10.20 MiB 0.19x 0.05 MiB/s 0.29 MiB/s
claude session --offline --json 0.00 MiB 28.4ms 4.1ms 6.95x 53.75 MiB 10.20 MiB 0.19x 0.05 MiB/s 0.38 MiB/s
codex daily --offline --json 0.00 MiB 29.1ms 2.6ms 11.34x 53.75 MiB 8.19 MiB 0.15x 0.03 MiB/s 0.33 MiB/s
codex session --offline --json 0.00 MiB 26.7ms 2.5ms 10.79x 53.50 MiB 8.19 MiB 0.15x 0.03 MiB/s 0.35 MiB/s

Large real-world-shaped fixture performance

Generated fixtures shaped from aggregate local log statistics: thousands of JSONL files, many small sessions, and a long tail of larger sessions. No real prompts, paths, or outputs are stored in the fixtures.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published native ccusage binary from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 0 warmups and 1 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude --offline --json 1.01 GiB 267.4ms 223.5ms 1.20x 952.32 MiB 894.33 MiB 0.94x 3.76 GiB/s 4.50 GiB/s
codex --offline --json 1.01 GiB 107.8ms 87.9ms 1.23x 431.04 MiB 417.28 MiB 0.97x 9.34 GiB/s 11.46 GiB/s

Artifact size

Artifact Base PR Delta Ratio
packed ccusage-*.tgz 18.08 KiB 18.08 KiB -0.00 KiB 1.00x
installed native package binary 3851.00 KiB 3858.44 KiB +7.44 KiB 1.00x

Lower medians and smaller artifacts are better. CI runner noise still applies; use same-run ratios as directional PR feedback, not release guarantees.

@github-actions

Copy link
Copy Markdown
Contributor

ccusage performance comparison

PR SHA: 519a249b988f
Base SHA: cd5d58fcaacf

This compares the PR package against the configured base package on the same CI runner.

Package runtime diagnostics

Compares the PR package wrapper, the installed native optional dependency binary, and the workspace release binary on the same large fixture. This identifies whether slow package results come from JavaScript wrapper overhead, the published native binary build, or the Rust core itself.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
All rows run --offline --json, measured by hyperfine with 0 warmups and 1 runs. This isolates wrapper overhead from the installed native optional dependency and the workspace release binary built on the runner.

Command Runtime Input Median Throughput Samples
claude --offline --json Package wrapper 1.01 GiB 246.5ms 4.08 GiB/s 1
claude --offline --json Installed native binary 1.01 GiB 215.3ms 4.68 GiB/s 1
codex --offline --json Package wrapper 1.01 GiB 117.7ms 8.56 GiB/s 1
codex --offline --json Installed native binary 1.01 GiB 95.8ms 10.51 GiB/s 1

Committed fixture performance

Committed small fixtures for stable PR-to-PR feedback and explicit Claude/Codex command coverage.

Fixtures: Claude apps/ccusage/test/fixtures/claude (0.00 MiB, 2 files), Codex apps/ccusage/test/fixtures/codex (0.00 MiB, 1 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published ccusage package from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 2 warmups and 7 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude daily --offline --json 0.00 MiB 28.4ms 29.8ms 0.95x 53.50 MiB 53.75 MiB 1.00x 0.05 MiB/s 0.05 MiB/s
claude session --offline --json 0.00 MiB 27.0ms 28.4ms 0.95x 53.50 MiB 53.50 MiB 1.00x 0.06 MiB/s 0.05 MiB/s
codex daily --offline --json 0.00 MiB 26.6ms 26.3ms 1.01x 53.75 MiB 53.75 MiB 1.00x 0.03 MiB/s 0.03 MiB/s
codex session --offline --json 0.00 MiB 26.1ms 27.0ms 0.97x 54.00 MiB 53.75 MiB 1.00x 0.03 MiB/s 0.03 MiB/s

Large real-world-shaped fixture performance

Generated fixtures shaped from aggregate local log statistics: thousands of JSONL files, many small sessions, and a long tail of larger sessions. No real prompts, paths, or outputs are stored in the fixtures.

Fixtures: Claude /home/runner/_work/_temp/ccusage-large-fixture (1.01 GiB, 2597 files), Codex /home/runner/_work/_temp/ccusage-large-codex-fixture (1.01 GiB, 2597 files)
Base runs the published ccusage package from pkg.pr.new, installed before measurement; PR runs the published ccusage package from pkg.pr.new, installed before measurement. Both run --offline --json, measured by hyperfine with 0 warmups and 1 runs.
Peak RSS is measured separately with /usr/bin/time using 1 runs. Lower RSS ratios are better.

Command Input Base median PR median PR vs base Base peak RSS PR peak RSS PR/base RSS Base throughput PR throughput
claude --offline --json 1.01 GiB 258.2ms 243.1ms 1.06x 884.32 MiB 928.33 MiB 1.05x 3.90 GiB/s 4.14 GiB/s
codex --offline --json 1.01 GiB 112.8ms 115.9ms 0.97x 421.03 MiB 425.29 MiB 1.01x 8.93 GiB/s 8.69 GiB/s

Artifact size

Artifact Base PR Delta Ratio
packed ccusage-*.tgz 18.08 KiB 18.08 KiB -0.00 KiB 1.00x
installed native package binary 3851.00 KiB 3858.44 KiB +7.44 KiB 1.00x

Lower medians and smaller artifacts are better. CI runner noise still applies; use same-run ratios as directional PR feedback, not release guarantees.

ryoppippi added a commit that referenced this pull request Jun 15, 2026
…refilter

Adopt the `LinePrefilter` helper (originally proposed in #1326) so the
typed `jsonl::records` path can prefilter on multiple markers with AND/OR
semantics instead of a single substring. `records` now takes
`Option<&LinePrefilter>`.

This restores the precise prefilters that single-marker filtering had to
drop:

- pi: require both `"usage"` and `"message"` (was `"usage"` only)
- kimi: require both `"StatusUpdate"` and `"token_usage"` (was
  `"token_usage"` only)
- openclaw: admit any of `"model_change"` / `"model-snapshot"` / `"usage"`
  (previously had no prefilter at all)
- qwen / copilot: unchanged markers, now via LinePrefilter

`LinePrefilter` builds the `memmem` needles once and reuses them across
every line. Output remains byte-for-byte identical (verified against
ccusage@latest for all agents with local logs). Supersedes #1326 by folding
its prefilter into the typed-parsing refactor.
@ryoppippi
ryoppippi merged commit 135ef29 into main Jun 15, 2026
33 checks passed
@ryoppippi
ryoppippi deleted the perf-unify-jsonl-prefilter branch June 15, 2026 15:58
ryoppippi added a commit that referenced this pull request Jun 15, 2026
…d helper (#1327)

* refactor(adapter): parse JSONL via typed structs and shared helper

Convert the agent adapters from dynamic `serde_json::Value` parsing plus
hand-written `Value::get` navigation to typed `#[derive(Deserialize)]`
structs parsed through the shared `adapter::jsonl` helper. Previously each
adapter built a full `Value` tree per line (and several allocated a
`String` per line via `BufReader::lines()`); they now read the file once,
prefilter lines with `memmem`, and deserialize only the fields ccusage
consumes.

Converted: amp, codebuff, copilot, gemini, goose, kilo, kimi, openclaw,
opencode, pi, qwen. Claude and Codex already used typed parsing.

Behavior is preserved exactly: the lenient `deserialize_with` helpers match
the old `Value` accessor semantics, prefilter markers are chosen to never
drop an accepted line (or `None` where no always-present substring exists),
and parse/non-object handling keeps the same skip-vs-error outcomes.
Adapters with no line-delimited JSON hot path (single-object or SQLite
sources) reuse only the leniency helpers. Droid is intentionally left on
its existing path: it has no JSONL hot path, so typing it would add edge
divergences with no performance benefit.

Verified output parity (daily/monthly/weekly/session, --json) against both
the previous main build and `ccusage@latest` for every agent with local
logs (claude, codex, amp, copilot, gemini, opencode, pi): byte-for-byte
identical.

* perf(adapter): prefilter JSONL lines with a shared multi-marker LinePrefilter

Adopt the `LinePrefilter` helper (originally proposed in #1326) so the
typed `jsonl::records` path can prefilter on multiple markers with AND/OR
semantics instead of a single substring. `records` now takes
`Option<&LinePrefilter>`.

This restores the precise prefilters that single-marker filtering had to
drop:

- pi: require both `"usage"` and `"message"` (was `"usage"` only)
- kimi: require both `"StatusUpdate"` and `"token_usage"` (was
  `"token_usage"` only)
- openclaw: admit any of `"model_change"` / `"model-snapshot"` / `"usage"`
  (previously had no prefilter at all)
- qwen / copilot: unchanged markers, now via LinePrefilter

`LinePrefilter` builds the `memmem` needles once and reuses them across
every line. Output remains byte-for-byte identical (verified against
ccusage@latest for all agents with local logs). Supersedes #1326 by folding
its prefilter into the typed-parsing refactor.

* fix(adapter): deserialize nested JSONL objects leniently

A malformed non-object nested field (e.g. "cache": 5) made the typed
struct fail to deserialize and silently dropped the whole usage record
via records().ok(). The pre-refactor Value navigation treated such a
field as absent and kept the otherwise usable tokens/cost. Add a shared
jsonl::lenient_object helper and apply it to the nested tokens/time/cache
fields in the opencode and kilo parsers to restore that behavior.

Co-authored-by: Codesmith <[email protected]>

* fix(gemini): compare the type discriminator untrimmed

The typed refactor read the gemini record discriminator with the
trimming non_empty_string helper, but the pre-refactor code compared it
with a raw Value::as_str (record.get("type").and_then(Value::as_str) ==
Some("gemini")). Trimming let padded values like " gemini " spuriously
match the "gemini" discriminator. Use the local untrimmed lenient_str
helper, which mirrors Value::as_str: strings verbatim, non-strings to
None without failing the line, so records still fall through to stats
parsing.

Co-authored-by: Codesmith <[email protected]>

* fix(pi): deserialize usage.cost leniently to keep records

A non-object `cost` payload made `PiUsage` deserialization fail, which
dropped the whole usage record via `jsonl::records().ok()`. The pre-refactor
`Value` navigation treated a non-object cost as absent display cost while
keeping the record's tokens. Apply `jsonl::lenient_object` to the nested
`cost` field to restore that behavior.

Addresses a CodeRabbit review finding on PR #1327.

* fix(adapter): keep amp and pi records on malformed nested shapes

The typed refactor regressed three lenient-navigation behaviors:

- amp messages were strict-deserialized as Vec<AmpMessage>, so a single
  non-object element (or a non-array messages field) dropped the entire
  thread. Parse them with the new jsonl::lenient_vec so bad elements are
  skipped, matching the old Value::as_array navigation.
- amp ledger precedence fired on any usageLedger object (including {}),
  so threads with no usable events array stopped falling back to message
  usage. Track events as Option<Vec<_>> via jsonl::lenient_array and only
  take the ledger branch when the events array is present, and read
  usage_ledger via jsonl::lenient_object so a non-object usageLedger no
  longer fails the whole thread.
- pi usage.cost was strict-typed, so a non-object cost dropped an
  otherwise usable record; read it via jsonl::lenient_object.

Adds jsonl::lenient_array / jsonl::lenient_vec helpers plus regression
tests for each path.

Co-authored-by: Codesmith <[email protected]>

* fix(openclaw): deserialize message leniently to keep model state

A non-object message field made OpenClawLine deserialization fail, so
jsonl::records().ok() dropped the whole line. For a model_change or
model-snapshot record that also carried a malformed message, this lost
the model/provider state update that subsequent usage entries rely on.
The pre-refactor Value navigation treated a non-object message as no
message while keeping the line, so apply jsonl::lenient_object to
restore that behavior.

Co-authored-by: Codesmith <[email protected]>

* style(claude): satisfy treefmt on null-field match arms

`nix flake check`'s treefmt wants the first `matches!` arm in
`is_unsupported_nullable_field` split across lines like the rest. Apply the
pinned formatter so the check passes; no behavior change.

---------

Co-authored-by: Codesmith <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant