NodeJSStructured Logging

Structured Logging

Structured logging means emitting each log entry as a machine-readable object of named fields — typically JSON — instead of a free-text sentence. The shift is from logs you read to logs you query. Once every field (userId, statusCode, durationMs, requestId) is a discrete, indexed key, your log platform can filter, aggregate, alert, and trace across millions of lines. This page covers the principle, correlation IDs for request tracing, propagating context with AsyncLocalStorage, a sensible field schema, and the practices that make logs genuinely useful in production.

Unstructured vs structured — the difference that matters

JS
// ❌ Unstructured — a human sentence; a machine sees one opaque string:
logger.info(`User ${userId} fetched order ${orderId} in ${ms}ms`)
// "User 42 fetched order 9981 in 87ms"
//   → to find slow requests you must regex-parse the message. Painful.

// ✅ Structured — discrete fields a log platform indexes:
logger.info({ event: 'order_fetched', userId: 42, orderId: 9981, durationMs: 87 })
{"level":"info","time":...,"event":"order_fetched","userId":42,
 "orderId":9981,"durationMs":87}

# Now you can run queries your log platform understands:
#   event:"order_fetched" AND durationMs:>500
#   userId:42 | count by event
#   avg(durationMs) by event over 1h
Structured logs turn 'grep the logs' into 'query the logs' — filter, aggregate, alert
The entire value of structured logging is that fields are *first-class*. With `durationMs` as a numeric field you can chart p95 latency, alert when it exceeds a threshold, or list the slowest endpoints — none of which is feasible when `87ms` is buried inside a sentence. The rule is simple and consequential: **put variable data in fields, keep the message constant.** The `msg` should describe the event class ("order fetched"), and everything that varies (ids, counts, durations) goes in structured keys.
Correlation IDs — tracing one request across many lines

Generate or accept a request id, attach it to every log of that request

JS
import { randomUUID } from 'node:crypto'

app.use((req, res, next) => {
  // Honour an incoming id (from an upstream gateway/service) or make one:
  req.id = req.headers['x-request-id'] || randomUUID()
  res.setHeader('x-request-id', req.id)       // echo it back to the client
  req.log = logger.child({ requestId: req.id })
  next()
})
# All four lines below share requestId — one query reconstructs the request:
{"requestId":"abc-123","msg":"request received","method":"POST","path":"/orders"}
{"requestId":"abc-123","msg":"validating payload"}
{"requestId":"abc-123","msg":"charging payment","amount":120}
{"requestId":"abc-123","level":"error","msg":"payment declined","code":"card_declined"}
A correlation ID lets you reconstruct a single request from interleaved logs
In production, thousands of requests log simultaneously and their lines are interleaved. A **correlation ID** (a.k.a. request/trace id) — a unique value attached to every log line of a request — lets you filter `requestId:"abc-123"` and read that one request's complete story in order. Generate it at the edge, or honour an `X-Request-Id` header passed from an upstream proxy/service so the id flows *across* service boundaries. Echoing it back in the response header means a user reporting an error can give you the exact id to search for.
Propagating context with AsyncLocalStorage

No more threading req.log through every function call

JS
import { AsyncLocalStorage } from 'node:async_hooks'

const als = new AsyncLocalStorage()

// Middleware: run the rest of the request inside a context store:
app.use((req, res, next) => {
  als.run({ requestId: req.id, userId: req.user?.id }, () => next())
})

// A logger that pulls context from the store automatically:
function log(level, msg, fields = {}) {
  const ctx = als.getStore() ?? {}
  logger[level]({ ...ctx, ...fields }, msg)
}

// Anywhere deep in the call stack — no req passed in, context still attached:
function chargeCard(amount) {
  log('info', 'charging card', { amount })   // includes requestId + userId
}
`AsyncLocalStorage` carries request context through async calls without passing it manually
Threading `req.log` (or a request id) through every function parameter is tedious and pollutes signatures. Node's built-in `AsyncLocalStorage` solves this: `als.run(context, cb)` makes `context` retrievable via `als.getStore()` anywhere in the async call chain spawned from that callback — across `await`s, timers, and promises — without passing it explicitly. A logger that reads the store automatically tags every line with the current request's id and user, no matter how deep in the stack the log call happens. It's the idiomatic way to do request-scoped context in modern Node.
A sensible field schema

Field

Purpose

timestamp / time

When it happened (ISO 8601 or epoch ms)

level

Severity (info, error, …)

msg

Constant, human-readable event description

requestId / traceId

Correlation across log lines and services

service / env

Which app and environment emitted it

userId / tenantId

Who the operation was for

event

Machine-friendly event name (order_placed)

durationMs

How long an operation took (for latency analysis)

err

Serialized error: message, stack, code

Agree on consistent field names across services — `userId` everywhere, not `user_id`/`uid`/`user`
Logs become exponentially more useful when field names are **consistent across your whole system**. If one service logs `userId`, another `user_id`, and a third `uid`, cross-service queries break and dashboards need special-casing. Standardize a small schema (the table above is a good starting point), document it, and apply it everywhere — ideally enforced through a shared logger module so individual call sites can't drift. Consistency is what lets you build one dashboard that works for every service.
Practices
  • Constant message, variable fieldsmsg:"order placed" plus { orderId, amount }, never the values baked into the string.

  • One event, one log line — don't split a single event across multiple lines; emit one object with all its context.

  • Always include a correlation id — it's the single most valuable field for debugging production.

  • Log the error object as a field ({ err }) so the stack and code are captured, not stringified to {}.

  • Use ISO 8601 or epoch timestamps — and log in UTC to avoid timezone confusion across regions.

  • Keep cardinality sane — avoid unbounded unique values (like full URLs with ids) as indexed fields where it explodes platform costs.

Structured doesn't mean safe — redaction still applies to every field you add
Switching to structured logging makes it tempting to log entire objects (`{ user }`, `{ body: req.body }`) for richness — but that's exactly how passwords, tokens, and PII end up indexed and searchable in your log platform forever. Every field you add is subject to the same [redaction](/nodejs/secrets-management) rules as before. Configure your logger to strip sensitive keys (Pino's `redact`, a Winston format), and prefer logging *specific* safe fields over spreading whole request/user objects.
Next
From logs to live system health: [Monitoring & APM](/nodejs/monitoring-apm).