Skip to main content
Observability

Structured logging: stop console-logging strings and start logging JSON

Your logs are a database you refuse to query. Emit one JSON object per event with stable fields, an event name, and a correlation ID, and diagnosis drops from a regex hunt to a GROUP BY.

Thien Nguyen
By Thien Nguyen
Updated July 21, 2026 · 3 min read

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"}
FieldWhy it exists
timestamp, levelOrder and filter — the two things you always slice by first
eventA stable name to alert and dashboard on
request_id, trace_idJoin this line to every other service's work on the same request
Domain fields (reason, client)Answer the question without parsing msg
msgHuman 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:

reasonapp_versioncount
expired4.2.061
expired4.1.x58
bad_signature4.2.02,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.

References

Primary documentation and specifications checked when this article was last updated.

ObservabilityLoggingDevOps

Related articles

All articles