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.
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 = 4812in the last hourlevel = error AND provider = stripe- average
durationMsformsg = "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
loggingmodule 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:
timeandlevel— 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. durationMsfor 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:
- Middleware generates an ID (or reuses an incoming
X-Request-Idheader from your proxy). - It creates a child logger with
{ requestId }bound. - All code handling that request logs through the child logger.
- 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.mdhow 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
msgstrings; values in fields -
requestIdon every line, returned in a response header -
userId, domain IDs, anddurationMswhere 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
Load Testing Your App Before Launch Day
Find out where your app breaks before your users do. What load, stress, spike, and soak tests reveal, writing a realistic k6 scenario with thresholds, reading p95 and error rates, finding the actual bottleneck, and the safety rules for testing without taking production — or a third-party API — down.
Getting AI to Write Tests That Actually Catch Bugs
Ask an AI for tests and you'll get plenty: tests that mock everything, assert nothing useful, and pass no matter what the code does. How to get tests that fail when behaviour breaks — what to test, how to prompt, how to check a test is real, and how tests become the agent's safety net.