I touched on structured logs briefly while writing about Node.js observability in production, as one ingredient among several. It deserves its own post, because “Add structured logging” is common advice that gets followed in a way that misses the actual point: teams switch from console.log("user logged in", userId) to a JSON logging library, and consider the job done. The format changed; the actual value — being able to answer a specific question fast during an incident — usually didn’t, because the fields that matter were never decided on. The format is the easy 20%. The fields, and the discipline to include them consistently, are the other 80%.
What “Structured” Actually Buys You
A plaintext log line is something a human reads top to bottom. A structured one is something a machine can filter, aggregate, and correlate:
// plaintext — searchable only by grepping for exact substrings
"User 4471 failed login attempt from 203.0.113.4 at 14:32:07"
// structured — queryable by any field, independently
{
"timestamp": "2026-09-05T14:32:07.421Z",
"level": "warn",
"event": "auth.login.failed",
"user_id": 4471,
"ip": "203.0.113.4",
"request_id": "a1b2c3d4"
}The plaintext version answers exactly one question well: “what happened at this specific moment,” if you already know roughly when to look. The structured version answers questions the plaintext line was never designed for — “how many failed logins from this IP in the last hour,” “show me every event tied to request_id: a1b2c3d4 across every service it touched” — because the fields exist independently of the surrounding sentence, and a log aggregator (Loki, Elasticsearch, Datadog) can index and query them directly instead of pattern-matching text.
The Field That Changes Everything: request_id
Of every field worth adding, one earns its place before all the others: a unique identifier generated once per request, at the edge (an API gateway or the first service that touches it), and threaded through every downstream call and every log line that request produces — including across service boundaries in a microservices setup, passed as a header:
// generated once, at the edge
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
// attached to every log call for the lifetime of this request
logger.info({ request_id: requestId, event: "order.created", order_id: 42 });
// forwarded to the next service, not regenerated
fetch(inventoryServiceUrl, {
headers: { 'x-request-id': requestId }
});Without this, debugging a slow or failed request across five microservices means guessing at timestamps and hoping the right log lines happened to land close enough together across five separate log streams to correlate by eye. With it, one query — request_id: a1b2c3d4 — returns the complete, ordered story of that single request’s path through every service it touched, which is the actual difference between an incident that takes ten minutes to diagnose and one that takes two hours of guessing.
Fields Worth Including by Default, and Why Each One Specifically
timestamp, ISO 8601, in UTC — not the log shipper’s arrival time, the moment the event actually happened at the source. A five-second gap between “event occurred” and “log shipped” is invisible until you’re correlating events across two services with slightly different pipeline lag, at which point it silently reorders your incident timeline.level, from a fixed, small set (debug,info,warn,error,fatal) — used consistently enough that “show me everything aterroror above, last hour” is a query you can actually trust, rather than one team’swarnbeing another team’sinfofor the same class of event.event, a stable machine-readable name (auth.login.failed, not the free-text sentence describing it) — this is what makes the field aggregatable and version-stable; a human-language message can be reworded by a future commit without anyone noticing it just silently broke a dashboard built on string-matching the old wording.- Whatever identifies the actor and the object —
user_id,order_id,tenant_id— as their own fields, not interpolated into a sentence. “Show me every event for this specific user across the last 24 hours” only works as a direct query if the ID is its own field.
The Discipline Part: Consistency, Not Cleverness
The reason most structured-logging efforts under-deliver isn’t a tooling gap — it’s that field names drift. One service logs user_id, another logs userId, a third logs uid nested inside a context object three levels deep. Every one of those is defensible in isolation and useless in aggregate, because a query for “everything about this user” now has to know and check three different field shapes across services instead of one. The fix isn’t a tool, it’s a shared, written-down convention — snake_case field names, a fixed list of standard fields every service includes, checked in code review the same way a naming convention for functions would be — enforced because nothing else will catch it before it’s baked into a year of historical logs that can’t be retroactively fixed.
What Not to Log, Because It Will Leak
Structured fields make it trivially easy to log something sensitive by habit — a password, a full credit card number, an auth token — because it’s “just another field” that seemed useful to include for debugging. The fix is a redaction step at the logging layer itself, not developer discipline alone (which will eventually miss one), stripping or masking known-sensitive field names before anything is written or shipped:
// pino example — redact by field path, not by remembering every call site
const logger = pino({
redact: ['password', 'req.headers.authorization', '*.creditCard'],
});This matters more than it looks like it does the moment logs ship to a third-party aggregator, or get retained for months in a system with broader read access than the application itself has — a leaked field in a log line is a data exposure with none of the access controls the original data source had.
The Checklist
- Generate a
request_idonce at the edge and forward it through every downstream call — this one field is worth more than the rest of the format change combined. - Fix a small, agreed set of standard field names before writing any logging code, and enforce it in review — drift here is what makes structured logs useless in aggregate.
- Use a stable, machine-readable
eventname separate from any human-readable message text. - Redact sensitive fields at the logging library level, not by trusting every call site to remember.
