DEV Community

Cover image for jq chokes on real-world log streams. So I built a tiny tool that doesn't.
benjamin
benjamin

Posted on

jq chokes on real-world log streams. So I built a tiny tool that doesn't.

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
Enter fullscreen mode Exit fullscreen mode
{
  "level": 30,
  "msg": "server started",
  ...
}
jq: error (at <stdin>:0): Cannot index string with "level"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Focus on just the fields you care about:

cat requests.log | logtidy --fields level,msg,req.method,req.url,res.status
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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's string → string (or null when 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/pipx it 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)