log-cardio 🏃🏼♂️
cardio is a fast Rust CLI that trims the fat from logs so LLM agents
spend fewer tokens reading them. Logs can be GBs and cardio streams them
line by line with near-constant memory (Bloom-filter dedup, capped
stats and trace maps). Agents can also fetch the logs themselves
(--gcloud, --bq) and learn the whole tool from one card
(cardio --agent-md).
The project is currently a WIP: it is taking its first steps and the test outcomes are all green so far. A thorough benchmark pass is planned (see Performance for the first numbers).
Install
With cargo (installs the cardio binary):
Without cargo (macOS and Linux; downloads the binary from the latest release):
|
Windows: grab the cardio-Windows-X64.exe from the
releases page and put
it on your PATH.
Or build from source:
First run: getting oriented
Cardio explains itself. Before reading any log, an agent (or person) should do these three things:
Writing AGENTS.md to your repo root once means agent runtimes pick it
up automatically. Agents never have to re-learn the tool by trial and
error, which is exactly the token cost this tool exists to save.
Input
⚠ The default pipeline drops lines. Two deliberate ways, both documented here so you can trust what you read:
- Repeated whole lines are removed. This uses a Bloom filter, so roughly 1% of dropped lines may actually be unique. cardio prints how many were dropped on stderr.
- Only the last 2000 lines are kept. The most recent lines win, and the head of the range is trimmed away.
--max-linesoverrides it.This is the right trade for noisy logs. When any line might be THE answer, run
--raw, or narrow with--queryand read everything you kept.
gcloud mode
--since and --until are pushed into the server-side filter as
timestamp clauses, so the download only covers the window you asked
for. Without time bounds the query streams the oldest entries first,
which can be very expensive. A warning tells you before that happens,
but the fix is yours: add --since (for example an ISO timestamp 7
days back) when you only need recent entries.
bq mode
bq mode runs bq query --format=csv and feeds the resulting rows
through the same pipeline (query, range, trace, summary, stats). You
pull trace data with SQL and trim it in one step. Rows are decoded as
CSV, so quoted fields with embedded commas or newlines stay a single
row, and the header row is skipped.
One thing to know: TIMESTAMP columns render in bq as
2026-10-13 12:00:00 UTC, and --since/--until cannot parse that.
Ask bq to emit ISO instead:
bq tips:
- The biggest lever is the SQL, not cardio. SELECT exactly the
fields you need (for example
jsonPayload.message), and never pull whole payloads withTO_JSON_STRING(payload). A wide SELECT costs more than any trim will save. cardio shines when the download is repetitive or broad; a narrow, trace-sized query is already optimal without it. --max_rows=10000000is passed to bq because bq silently caps selects at 1000 rows otherwise.- Column names print once on stderr (
Note: bq columns: a | b | ...), so you always know which value is which. Rows join their fields with a tab, and empty or null columns keep their separator instead of vanishing. --since/--untilare NOT pushed into bq, because the SQL is yours. Filter dates in the query with aWHEREclause overFORMAT_TIMESTAMP(...)text, and the same ISO bounds still apply locally.--max-lineskeeps the last N rows. The whole bq output is read first: the cap no longer trims the download itself.
Flags
| Flag | Effect |
|---|---|
[file] |
Log file. If omitted, reads from stdin. |
--query TEXT |
Keep only lines containing TEXT (substring, applied first). |
--since TS |
Timestamp >= this. Accepts ISO (2026-10-09T05:00:00) or YYYY-MM-DD (day start). |
--until TS |
Timestamp < this. ISO, or YYYY-MM-DD (NEXT-day start, so the end day is kept). |
--raw |
Skip the default pipeline: verbatim lines, no trim of timestamps or boilerplate, no dedup, no cap. |
--tail N |
Only the LAST N kept lines (after dedup and query). Reads the whole input, replaces the cap. |
--max-lines N |
Same tail semantics as --tail; in --trace/--summary mode it caps events per run instead. |
--max-field-len N |
Cut any field (or whole line) longer than N chars and mark the cut with .... |
--json |
NDJSON output: {"index","content"} per line; with --trace, {"id","events"}; with --summary, {"id","count","first","last"}. |
--trace REGEX |
Group lines by captured ID, for example "request_id=([a-z0-9-]+)". Sessions print at the end. |
--summary |
With --trace: compact per-ID map with event count plus first and last events. IDs preserved. |
--top N |
In trace mode (full or summary): keep only the biggest N sessions; the rest drop with a note. |
--stats |
Survey mode: top 20 pattern frequencies on stderr, no stream output. Digit runs fold to # first, so sid=123 and sid=456 count as one pattern. --stats --json prints NDJSON {"pattern","count"} on stdout. |
--dedup |
Force dedup in --raw mode. Dropped count reported on stderr. Default pipeline already has dedup on. |
--gcloud FILTER |
Stream Cloud Logging entries matching this filter. --since/--until are pushed server-side. |
--bq SQL |
Run the bq CLI with this SQL and process its rows. Exactly one source at a time. |
--project ID |
bq only: the GCP project that runs the job. Defaults to the CLI's configured project. |
--max-bytes-billed N |
bq only: hard cap on billed bytes. The query errors instead of scanning multi-TB tables. |
--dry-run |
bq only: cost estimate only. Runs bq query --dry_run and reads no rows. |
--bq-args "--flag=..." |
bq only: pass extra raw bq CLI flags (split on whitespace, no quotes inside). |
--timeout SECS |
bq or gcloud: kill a hung fetcher after SECS. Its job id stays visible on stderr. |
--config FILE |
TOML file with timestamp_regex, boilerplate_regex, default_trace. |
--agent-md |
Print the agent-facing markdown card (pipe it: cardio --agent-md > AGENTS.md). |
Performance
./bench.sh builds the release binary and times every mode on a
synthetic 1M-line log (50% duplicate lines, digit ids, huge XML
bodies). It uses hyperfine when installed and falls back to a plain
monotonic-clock loop. First run (M1, release build):
| mode | time per 1M lines |
|---|---|
cat baseline |
0.02 s |
--query |
0.12 s |
--raw (no pipeline) |
0.20 s |
--trace (raw, uncapped) |
0.38 s |
| lean (default: trim + dedup + cap) | 0.51 s |
--stats |
0.65 s |
That is roughly 65 MB/s on the lean path end to end. The trim runs as a
single combined regex pass. When the input comes from gcloud or bq, the
network dominates anyway. The bench's trace scenario runs with --raw
on purpose: in lean mode the session cap bounds the input early, so its
time would measure the cap rather than the grouper.
Tests
Philosophy: black-box, at the CLI level. Tests pin the promises the
agent card makes (trim behavior, dedup honesty, exit codes, stderr
witnesses, JSON shapes). Because of that they survive refactors, and
any drift between docs and behavior fails loudly. gcloud and bq are
faked with PATH-stub shell scripts (marked #[cfg(unix)]), so the
whole suite runs offline on every OS. Unit tests exist only for pure
math that a CLI test cannot probe sanely, such as calendar rollovers
and the 9999-12-31 clamp.
Time handling
ISO timestamps compare lexicographically, so string order equals time
order. Timezone offsets are matched and trimmed for comparison, but the
comparison is tz-naive: mixing +02:00-suffixed logs with UTC bounds
can misjudge hours around day boundaries.
Config example
= '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
= '(?i)\b(INFO|DEBUG|TRACE)\b'
= 'trace_id=([a-zA-Z0-9-]+)'
Examples
# agent-ready error digest
|
# what happened to one request
# same, compact: first/last event + count per request
# survey a 10GB log without streaming output