Log mining
Forensic queries over log trees, live streams from any command via stdin, and the interactive logs session that holds a stream while you iterate on filters.
Goal: answer “which logs contain X, how many times, and what’s around them” using crust’s mixed shell + TS pipeline. Use grep when grep is enough; reach for a filter stage when the predicate is structural, and a TS lambda when you need JSON parsing or per-line shaping.
Count error lines across a tree
lines **/*.log | grep ERROR | wc -l // glob + line stream + filter + reduce
const errors = await glob("**/*.log")
.pipe(async (p) => Bun.file(p).text())
.pipe((text: string) => text.split("\n"))
.map((line: string) => line)
.filter((line: string) => line.includes("ERROR"))
.collect();
console.log(errors.length); Filter with a TS predicate
When you want a structural filter — say, only lines from a specific user — a filter stage is friendlier than a grep regex.
# grep narrows files to ERROR lines; filter applies the TS predicate per line
lines **/*.log | grep ERROR | filter (l => l.includes('user=42')) await read("application.log")
.filter((line) => line.includes("ERROR") && line.includes("user=42"))
.to(write("/tmp/user-42-errors.log")); filter (line => …) keeps items whose predicate is truthy and drops the rest (plain JS truthiness — 0, "", and null all drop). Async predicates are awaited; thrown errors fail the pipeline. A plain (line => …) lambda is different: it maps — a boolean-returning lambda emits true/false lines instead of dropping anything, and an empty filter result passes silently (use assert when nothing-matched should fail).
Structured logs: JSON per line
If your logs are JSON-per-line, parsing and projecting is one lambda each.
# Read each .log file, split into lines, parse as JSON, keep
# severity=error, project the message field
lines **/*.json.log
| grep .
| (line => JSON.parse(line))
| filter (j => j.severity === 'error')
| (j => j.msg) // Same pipeline, .ts file (bun script.ts)
await glob("**/*.json.log")
.pipe(async (path) => Bun.file(path).text())
.pipe((text: string) => text.split("\n").filter(Boolean))
.map((line: string) => JSON.parse(line) as { severity: string; msg: string })
.filter((j) => j.severity === "error")
.map((j) => j.msg)
.to(write("/tmp/errors.txt")); Shell stages linearize: whatever they print becomes one item per output line, which is why
grep .turns whole-file items into per-line items (and drops blanks). A mid-pipeline lambda does not flatten arrays —(text => text.split('\n'))emits the whole array as a single item. Array flattening only happens for function-as-source return values.
Group + count via reduce
# Extract error codes; filter drops the lines that had none
lines **/*.log
| grep ERROR
| (l => (l.match(/ERR_[A-Z0-9_]+/) ?? [null])[0])
| filter (c => c)
# → stream the codes; pipe into your favorite counter // Cleaner in TS — reduce into a map
const counts = await glob("**/*.log")
.pipe(async (p) => Bun.file(p).text())
.pipe((t: string) => t.split("\n"))
.map((l: string) => l.match(/ERR_[A-Z0-9_]+/)?.[0] ?? null)
.filter((c): c is string => c !== null)
.reduce<Record<string, number>>((acc, code) => {
acc[code] = (acc[code] ?? 0) + 1;
return acc;
}, {});
console.table(counts); Tail-and-watch
Crust ships a native tail source — last-N lines, tail -F-style follow, multi-file merging, and rotation detection — so log streams stay inside the pipeline model without shelling out.
# Last 100 lines of a log, filter ERRORs through grep
tail -n 100 application.log | grep ERROR
# Follow forever, alert on errors
tail -F application.log | grep ERROR | POST :9000/alerts
# Follow only — skip the initial cut, only stream new lines
tail -F -n 0 application.log | (l => JSON.parse(l))
# Multi-file: glob or explicit list. Lines from all files merge into one stream.
tail logs/*.log | grep ERROR > combined.log
tail -F services/*/access.log | (l => l.toUpperCase()) | tee combined.log
tail -F api.log worker.log scheduler.log // Last 50 lines, ship to a log sink
await tail("application.log", { lines: 50 })
.pipe(POST("https://logs.example.com/ingest"))
.collect();
// Follow forever, alert on ERROR lines
for await (const line of tail("application.log", { follow: true }).lines()) {
if (line.includes("ERROR")) await notifyPager(line);
}
// Multi-file: glob string OR array of paths
await tail("logs/*.log", { lines: 100 })
.filter((l) => l.includes("ERROR"))
.to(write("errors.log"));
// Tail every service's access log, page on 5xx
for await (const line of tail("services/*/access.log", { follow: true }).lines()) {
if (/5\d\d/.test(line)) await notifyPager(line);
} tail mirrors POSIX flags — -F/-f for follow, -n N or --lines=N for the initial cut. The TS API takes tail(paths, { lines, follow, pollMs }), with defaults lines: 10, follow: false, pollMs: 200. paths is a string (path or glob) or an array.
Multi-file semantics: each file gets its own inode/size loop, rotation handling, and initial-N-lines cut. Lines yield as they arrive — non-deterministic across files, deterministic within one file. The same tail -f a.log b.log behavior you’d get from POSIX.
Rotation: the follow loop tracks each file by inode and size. A rotate-and-recreate (logrotate, mv app.log app.log.1 && touch app.log) is detected via inode change, and the stream switches to the new file automatically. A truncate that shrinks the file below the current offset is also detected and resets the stream. A truncate-and-immediate-overwrite to a size ≥ the prior offset is indistinguishable from an append via stat alone, so it’s treated as an append — matches GNU tail -F behavior.
Falling back to system tail: unrecognized flags (-c, --pid, …) fall through to the system binary via the shell, so tail -c 200 app.log and tail --help keep working.
Bounded initial read: the -n N cut scans backward from EOF in 64KB
blocks and reads only the last-N-lines window — tail -n 3 on a 500k-line
log answers in ~20ms without ever holding the file in memory, and
tail -n 0 -F reads nothing before following.
Live streams: two traps crust removes
tail -F app.log | grep ERROR is the classic follow pipeline — and with
system grep it silently stalls, because GNU grep block-buffers ~4KB when
writing into a pipe. Crust runs the safe grep subset (-i/-v/-F, one
pattern) as a native line-buffered stage, so every match streams the
moment it arrives; anything fancier (combined flags, $VARs, POSIX
classes, file grep) still gets exact system-grep semantics via sh.
The second trap: getting any command’s output into the pipeline. The
stdin source (alias -) bridges the invoking pipe:
# Live error feed from a container, pino JSON → filtered → projected
docker logs -f my-app | crust -c 'stdin | (l => { try { return JSON.parse(l) } catch { return null } }) | filter (e => e && e.level >= 40) | (e => e.msg)'
# Rolling latency/status windows over live traffic
docker logs -f my-app | crust -c 'stdin | (l => { try { return JSON.parse(l) } catch { return null } }) | filter (e => e && e.status) | (e => ({status: e.status, ms: e.duration_ms})) | stats --every 3'
# journald, priority err and above
journalctl -f -o json | crust -c 'stdin | (l => JSON.parse(l)) | filter (e => e.PRIORITY <= 3) | (e => e.MESSAGE)'
Real capture of the second line against a container under load:
{"window":1,"count":121,"wallMs":3002,"rps":40,"status":{"200":121},"p50":1,"p95":1,"p99":2,"meanMs":0.9}
{"window":2,"count":120,"wallMs":3002,"rps":40,"status":{"200":120},"p50":1,"p95":1,"p99":2,"meanMs":0.8}
Wrap JSON.parse in a try/catch on mixed streams — real container logs
carry startup banners between the JSON lines, and a bare parse dies on the
first one. Note the bare-pipe rule: cmd | crust treats piped stdin as a
script, so data pipes always go through crust -c 'stdin | …'.
Interactive: hold the stream, iterate on filters
Re-running a follow pipeline restarts the tail and loses the recent past.
The logs builtin holds one live source open, buffers the last 10k items
(--buffer N to change), and every line you type is an ordinary pipeline
fragment run over that buffer first, then over the live stream:
logs docker logs -f --tail 300 my-app # any shell command's stdout
logs tail -n 0 -F app.log # or a file follow
logs procs({web: "bun run dev"}) # or a whole process group
A real session against a live container:
logs: docker logs -f --tail 300 my-app — buffering last 10000 items
logs> grep api_request
{"level":30,"msg":"api_request","path":"/api/health","status":200,"duration_ms":1}
… ← matches from the buffer (the recent past)
-- live --
… ← new matches as they arrive
^C
logs> json on
json: on — string items parsing to JSON objects/arrays reach queries parsed
logs[json]> filter (e => e.status) | (e => e.path + " " + e.duration_ms + "ms")
-- live --
/api/health 1ms
/api/health 0ms
^C
logs[json]> search timeout
search: 2 matching item(s) of 551 buffered
logs[json]> buffer 50000
buffer: resized 10000 → 50000 (kept 551 item(s))
logs[json]> exit
json on is the session’s honest answer to NDJSON streams: parsing happens
at query time (the buffer stays raw, json off reverts instantly), lines
that aren’t JSON objects flow through unchanged and are counted out
loud — so a mixed stream can neither crash a query (the old
(l => JSON.parse(l)) lambda threw on the first plain line) nor silently
lose data. search <text> answers instantly from the buffer with a match
count (zero included) and highlighting; buffer N widens the window
without restarting the held source.
Because every query is ordinary pipeline grammar, rendering is just
another stage — filter on the raw JSON first, then hand the matches to
pino-pretty:
logs> grep api_request | pino-pretty --colorize --singleLine
[11:46:18.313] INFO: api_request {"request_id":"…","path":"/api/health","status":200,"duration_ms":4}
-- live --
…colorized lines keep streaming until ^C…
No node_modules/.bin/ prefix needed: shell stages get npm-run-style
PATH — every ancestor node_modules/.bin is prepended, so
locally-installed tools work bare. --colorize forces ANSI even though
pino-pretty runs behind a pipe. Keep the grep/filter before the
pretty stage — after it, you’d be matching against ANSI-decorated text.
The parts worth knowing: Ctrl-C once ends the live view gracefully —
the stream finishes, so a query ending in bare stats prints its summary
exactly there; Ctrl-C twice hard-cancels a stuck query; the session and
the held source survive both. A fragment runs twice (buffer, then live), so
append with >>, never >. exit/Ctrl-D tears the held source down —
procs groups get the SIGTERM→SIGKILL escalation.
Tips
- Mix
grepand lambdas —grepis unbeatable for plain substring/regex filters; reserve lambdas for things grep can’t see (JSON shape, async lookups, multi-line state). linesvsreadvsBun.file().text()— the shell stageread <glob>yields whole-file items (one per file),lines <glob>yields one item per line, and the TypeScriptread(path)streams lines like the latter. They print identically on a terminal, so the difference only shows when a downstreamfilter/lambda sees one giant string instead of lines.grepmasks it by splitting internally — which is whyread **/*.log | grep ERROR | filter (…)works and swapping those last two stages silently does not.- Pair with
time "label"to know how long a forensic query takes — the timer fires even if a downstream stage throws.
Related
- Intro — refresher on sources, transforms, sinks.
- Quickstart — every source/transform/sink in one tour.