If you've ever piped a dev server's output into jq to make the JSON logs
readable, you know this dance:
npm run dev | jq
{
"level": 30,
"msg": "server started",
...
}
jq: error (at <stdin>:0): Cannot index string with "level"
It worked for exactly three lines — until your app printed a startup banner, or a
plain console.log, or a stack trace. jq hit one line that wasn't valid JSON
and aborted the entire stream. Now you're back to reading raw JSON with your
eyeballs.
The thing is, real log output is never pure JSON. It's a mix: structured lines
from your logger (pino, winston, structlog, zap…), plus framework noise, plus
whatever you print()-debugged at 2am. Any tool that demands the whole stream be
JSON is the wrong tool for an actual terminal.
So I built logtidy — a zero-dependency CLI that pretty-prints the JSON lines
and passes everything else through untouched.
npm run dev 2>&1 | npx logtidy
08:15:30.123 INFO server started port=3000 env=dev
08:15:31.044 WARN slow query db.ms=1200 table=users
this is a plain console.log — it just flows through
08:15:31.910 ERROR request failed method=POST path=/api/pay status=500
TypeError: cannot read property 'id' of undefined
at handler (/app/routes/pay.js:42:18)
The JSON gets humanized into one tidy line each — timestamp, a colored level
badge, the message, then the rest as flattened key=value pairs. Everything that
isn't JSON (that banner, that stack trace) flows straight through. You never
lose context, and the pipe never dies.
It speaks the common logger dialects
You don't configure field names. logtidy looks for the usual suspects:
-
timestamp:
time/ts/timestamp/@timestamp -
level:
level/lvl/severity/levelname— as a word ("warn","WARNING") or a pino/bunyan number (40) -
message:
msg/message/text/event
Nested objects get flattened to dot-paths, so {"req":{"method":"GET","url":"/x"}}
becomes req.method=GET req.url=/x. Pino apps, Python structlog, a hand-rolled
JSON logger — they all just work.
Two flags you'll actually use
Filter by level (lines without a recognizable level are always kept, so you don't
lose a stack trace):
docker logs -f app | logtidy --level warn
Focus on just the fields you care about:
cat requests.log | logtidy --fields level,msg,req.method,req.url,res.status
Install
It ships on both registries, because half the world's logs are Node and half are
Python:
npx logtidy # Node — zero deps
pipx run logtidy # Python — pure stdlib
Both ports are tested against the same input→output vectors, so they format
byte-for-byte identically. Pick whichever runtime is already on the box.
A few design notes
-
One pure function at the core.
formatLine(rawLine, opts)has no I/O, no clock, no globals — it'sstring → string(ornullwhen a line is filtered). The CLI is a thin stdin→stdout wrapper. That's why the Node and Python builds can be proven identical: they share one test table. -
Timestamps never touch a
Date/datetime. ISO strings have their time portion sliced out as-is; epoch numbers are reduced to UTC time-of-day with plain integer math. Deterministic, timezone-free, identical across languages. -
It won't break your pipe. Non-JSON in → same line out.
| head(EPIPE) exits cleanly instead of vomiting a traceback. -
Zero dependencies, zero config, no daemon. It's a filter.
npx/pipxit on demand and forget it exists until the next noisy log.
Try it / break it
Code, issues, and the full README:
It's MIT and tiny. I'd love to know which logger format it doesn't handle well —
paste me a line it mangles.
What are you using today to read JSON logs in your terminal?
Top comments (0)