All posts
6 min read

Structured Logging: Logs You Can Actually Search

console.log('user saved') is useless at 3am when you need every request user 4812 made in the last hour. How structured logs work, what fields to include, request IDs that tie a request together, log levels that mean something, what never to log, and how logs make AI agents better debuggers.

infrastructuredebuggingdeveloper experienceintermediate

Most small apps log like this:

console.log('saving user')
console.log('saved!', user)
console.log('error', err)

It works while you're watching the terminal. It fails completely when a customer reports "checkout broke for me around 2pm yesterday" and you have 40,000 lines of unlabelled text from four app instances to search.

Structured logging fixes that. It's a small change with an outsized payoff.

What structured logging means

Instead of free-form text, each log line is a record with named fields, usually JSON:

{"time":"2026-09-24T14:02:11.418Z","level":"error","msg":"payment failed","requestId":"req_8f2c","userId":4812,"orderId":"ord_991","provider":"stripe","errorCode":"card_declined","durationMs":842}

Now your log tool can answer questions directly:

  • userId = 4812 in the last hour
  • level = error AND provider = stripe
  • average durationMs for msg = "payment failed"
  • everything with requestId = req_8f2c

Text logs need regex archaeology. Structured logs are queries.

Use a logging library

Don't hand-roll JSON with console.log. Use a library that handles levels, fields, and serialisation efficiently:

  • Node: Pino (fast, JSON by default), Winston
  • Python: the standard logging module with a JSON formatter, or structlog
  • Go: the standard library's log/slog
  • Most frameworks have a recommended option.

With Pino:

import pino from 'pino'
const log = pino({ level: process.env.LOG_LEVEL ?? 'info' })

log.info({ userId: user.id, plan: 'pro' }, 'subscription upgraded')
log.error({ err, orderId }, 'payment failed')

In development, pipe the output through a pretty-printer (like pino-pretty) so it's readable; in production, emit raw JSON for your log platform.

The fields worth including

Consistently, on every relevant line:

  • time and level — the library adds these.
  • msg — a short, constant description ("payment failed"), not a sentence with values baked in. Constant messages are groupable; "payment failed for order 991" produces a thousand unique messages.
  • requestId — see below.
  • userId (or account ID) — who was affected.
  • Domain IDs — orderId, invoiceId, jobId — whatever lets you follow a thing through the system.
  • durationMs for operations that can be slow — external calls, queries, jobs.
  • err — the error object, with its stack trace (libraries serialise it properly).
  • Service / release info — service name, version or commit SHA — so you can see whether an error started with a deploy.

Request IDs: tie it all together

A single request touches many log lines: received, authenticated, queried, called Stripe, responded. A request ID — a unique value generated when the request arrives — attached to every line lets you pull up the whole story at once.

The usual pattern:

  1. Middleware generates an ID (or reuses an incoming X-Request-Id header from your proxy).
  2. It creates a child logger with { requestId } bound.
  3. All code handling that request logs through the child logger.
  4. The ID is returned in a response header — so when a user reports a problem, support can ask for it.

In Node, AsyncLocalStorage lets you reach the request-scoped logger anywhere without passing it through every function. Pass the ID along to background jobs you enqueue, too, so you can follow work that continues after the response.

Log levels that mean something

Levels are only useful if they're used consistently:

Level Use for
error Something failed and needs attention. Should be rare enough that each one matters.
warn Unexpected but handled — a retry, a fallback, a deprecated path.
info Significant business events — sign-up, payment, job completed.
debug Detail for diagnosing problems — off in production by default.

Two rules: don't log expected conditions as errors (a user typing a wrong password is not an error), and alert on error rate, not on every error. (How to Know When Your App Is Down.)

What never to log

Logs get copied, shipped to third-party services, retained for months, and read by many people. Never log:

  • Passwords, including failed login attempts' passwords
  • Session tokens, API keys, auth headers, cookies
  • Full payment card data
  • Sensitive personal data — health, government IDs — and be sparing with ordinary personal data (email addresses, IPs) depending on your privacy obligations (Does My App Need a Privacy Policy?)

Use your library's redaction feature so a stray log.info({ req }) can't leak headers:

const log = pino({
  redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
})

And be careful with logging whole objects — log.info({ user }) logs every field on the user, including ones added later.

Where logs should go

Write logs to standard output and let the platform collect them — don't manage log files from inside the app. From there, ship them somewhere searchable with retention you control: your host's log viewer, or a log platform. Keep at least a couple of weeks; many issues are reported days after they happen.

Logs and AI agents

Structured logs make coding agents dramatically better at debugging. Given a request ID, an agent can pull every related line, see the exact sequence, and find the failing step — instead of guessing from a user's description. Two tips:

  • Document in CLAUDE.md how to query logs (the command or tool, and the common fields).
  • Let the agent read development and staging logs directly; give it production log access only in a read-only, redacted form.

The same logic applies to your own debugging: when you're stuck in an AI fix loop, the way out is real evidence — and structured logs are where the evidence lives.

Beyond logs

Logs are one of three pillars of observability, alongside metrics (numbers over time — request rates, latency percentiles) and traces (the timing of every step of a request across services). OpenTelemetry is the standard for all three. For a small app, well-structured logs with request IDs and durations get you most of the value; add metrics and tracing when you outgrow them.

The checklist

  • A logging library emitting JSON in production
  • Constant msg strings; values in fields
  • requestId on every line, returned in a response header
  • userId, domain IDs, and durationMs where relevant
  • Levels used consistently; debug off in production
  • Redaction for secrets and sensitive data
  • Logs to stdout, shipped somewhere searchable with retention

EasySpawn runs your app, workers, and Claude Code in the same workspace, so the agent can read your app's logs directly when it's debugging — evidence instead of guesses. See how it works or join the waitlist.

Related: How to Read an Error Message · Database Indexes · Secrets Management Beyond .env Files

Keep reading