Engineering Practice
Structured Logs Beat Clever Sentences
A log line is a message to a future reader — usually you at 3am, often a query engine. Design logs for that reader with stable messages, structured context, and deliberate levels instead of concatenating values into prose.
- Engineering Practice
- Observability
- Logging
- Operations
Most logging is written for an audience that doesn’t exist: a human reading the file top to bottom, in order, at leisure. Nobody does that. The real readers are you at 3am grepping for one request, and a query engine trying to group ten thousand events. Once you write for those readers, a lot of common logging habits look wrong — starting with the urge to build a nice sentence out of your variables.
Logs have a consumer, and it isn’t you-right-now
The mistake underneath bad logging is treating a log call as a place to narrate. You know exactly what’s happening as you write the code, so you describe it in a fluent English sentence with the values dropped in. That sentence is optimized for the one reader who will never show up: present-you, who already understands the situation.
The readers who matter are different. Future-you wants to find every instance of one kind of event fast. The aggregator wants to count them, filter them, and alert on them. Both of those readers want structure — fields they can match on — not prose they have to parse with a regex. Design for them and the log stops being a diary and starts being data.
Stable message, structured context
The single highest-leverage habit: keep the message a short, stable string, and put the variable parts in a structured context object beside it. Don’t interpolate the values into the message.
// Avoid — a unique message per call; nothing to group or filter on
log.info("Synced order 48213 for user 9920 in 412ms")
// Prefer — stable message, queryable fields
log.info("order synced", { order_id: 48213, user_id: 9920, duration_ms: 412 })
The difference shows up the moment you have volume. The first form produces a million distinct strings — you can’t count “order synced” events, because every one is textually unique. The second form gives you a stable event name to group on and fields to filter and aggregate by. “Show me synced orders slower than 500ms” is a query against the second form and an act of desperation against the first.
Group on the message, filter on the context. If your message contains the variable, you’ve made it ungroupable to save yourself one object literal.
Levels are a contract, not decoration
Log levels exist on a scale for a reason, and the reason is that downstream systems make decisions based on them. A channel’s configured level is a floor — set it to warning and everything below is dropped — and alerting keys off severity. So picking a level isn’t flavor text; it’s you declaring “a human should look at this” or “this is routine.”
Reserve error and critical for things that actually warrant attention. Keep the expected, routine flow at info or debug. When everything is logged at error because it felt important while typing, you’ve trained everyone to ignore errors — and now the one that matters scrolls past with the noise. The level is a promise to the reader about how much they should care; keep it honest.
One channel per concern
In any app big enough to have several independently-evolving parts — auth, billing, an external integration — route each concern to its own logging channel. The payoff is isolation: a chatty discovery component doesn’t bury billing errors, each trail can have its own level and retention, and you can ship the security-audit trail to one place and the operational logs to another without touching unrelated code.
One small discipline makes this maintainable: don’t scatter channel-name string literals through the codebase. Resolve the channel from config and inject the resolved logger. Then the name lives in one place, it’s swappable per environment, and tests can hand a component a spy logger to assert on. When the log is the feature — an audit trail, say — being able to test that it logged the right event at the right level matters as much as any other behavior.
The dual-sink habit
A pattern I reach for constantly: log each concern to two sinks at once. A verbose local sink keeps the full trail for forensics, while a filtered forward sends only the actionable stuff to your aggregator or error tracker. The file leg can run at info so you have everything when you need to reconstruct what happened; the forwarded leg inherits the higher production floor so only signal — not the full firehose — reaches the place that pages people.
In containers this folds neatly into the twelve-factor habit of logging to standard output and letting the platform collect, tag, and forward. The app shouldn’t be in the business of shipping its own logs when the runtime already does it, tagged per stream, for free. Don’t make the application reinvent log shipping it gets handed.
The two rules that never bend
Two things are non-negotiable regardless of structure:
- Never log secrets or PII. No passwords, tokens, keys, raw auth headers, or personal/customer data. Redact before it ever reaches a log call. A log pipeline is one of the easiest places to accidentally leak the exact thing you spent the rest of the system protecting.
- Truncate large payloads, and correlate across components. Cap external request/response bodies so one fat response doesn’t bloat the file (or the bill), and stamp a correlation id onto every entry for a request so its logs line up across every component — and ideally cross-link to traces.
None of this is exotic, and that’s the point. Good logging is mostly the discipline of remembering who reads logs and writing for them instead of for the moment you’re typing. It’s the same instinct behind logging why something failed, not just that it did and tagging telemetry at the source: your observability is a product with users, and your monitoring is production too. If you want to argue about log levels — and people love to — I’m easy to reach.