Your logs are a database, and you're refusing to treat them like one. Every log aggregator — Loki, Elasticsearch, CloudWatch, Datadog — is a query engine waiting for structured input. Feed it console.log("payment failed for user " + id) and you've handed a query engine a paragraph of prose to full-text-search. Feed it a JSON object and you can GROUP BY provider in one line.
The difference isn't cosmetic. It's whether "which provider is failing right now?" is a two-minute query or a regex archaeology dig conducted while checkout is down.
{"level":"warn","event":"auth.token_rejected","request_id":"a_5c1","reason":"expired","route":"/api/orders","client":"ios-app","app_version":"4.2.0"}| Field | Why it exists |
|---|---|
timestamp, level | Order and filter — the two things you always slice by first |
event | A stable name to alert and dashboard on |
request_id, trace_id | Join this line to every other service's work on the same request |
Domain fields (reason, client) | Answer the question without parsing msg |
msg | Human context — kept, but never the thing you query |
A worked incident
Alert fires: 401 rate up 5x right after this morning's deploy. With structured logs you run one query — group event=auth.token_rejected by reason and app_version over the last 15 minutes:
| reason | app_version | count |
|---|---|---|
expired | 4.2.0 | 61 |
expired | 4.1.x | 58 |
bad_signature | 4.2.0 | 2,140 |
The answer is on the screen: only clients on 4.2.0 are getting bad_signature, so this isn't users with stale tokens — the deploy rotated a signing key the new build validates against and the old one doesn't. You roll back and move on. With string logs, that table doesn't exist — you'd be writing a regex to pull the version out of freeform messages that three different engineers phrased three different ways.
Log events, not diary entries
Name events as domain.outcome: invoice.payment_failed, auth.token_rejected, cache.refresh_started. The domain groups them; the outcome makes them alertable. The one rule that bites people: never put a dynamic value in the event name. user_42_failed_login creates a new event name per user — unbounded cardinality that destroys aggregation and, on metered backends, quietly runs up the bill. The user id is a field, not part of the name.
FAQ
Doesn't JSON make logs unreadable in local dev?
That's what pretty-printers are for. Log JSON in production, pipe it through pino-pretty or | jq locally. You get machine-queryable in prod and human-readable at your terminal — not a trade-off.
Won't structured logs cost more to store?
Usually less. Ingestion cost tracks volume and indexed fields, and structured logs let you index a handful of fields instead of full-text-indexing every message. The bigger win is fewer log lines: one rich event replaces the five console.logs you sprinkled in to reconstruct state.
Do I have to convert everything at once?
No. Start with the three or four events your team debugs most — the payment path, auth, the flaky external call. Structured logging pays off exactly where you already spend incident time.
Isn't JSON safe to dump everything into?
No — structure is not permission. Redact Authorization headers, cookies, passwords, and tokens before the event leaves the process. A logged secret is worse in JSON, because now it's a tidy, greppable, exportable field.
Structured logging earns its keep when the fields mirror the decisions you make mid-incident. Log the values you'd want to GROUP BY at 2am, and the 2am query writes itself.
Cover photo by Al Nahian on Pexels.
