Structured Logging for Production: The Setup You'll Thank Yourself For
How to implement structured logging in production apps — JSON logs, correlation IDs, log levels, and shipping to a searchable backend that makes debugging fast.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Structured Logging for Production: The Setup You'll Thank Yourself For
The first time I had to debug a production incident using console.log output, I swore I would never do it again. Unstructured logs are a wall of text. Searching them means grep and prayer. Correlating an error across multiple services means reading timestamps and trying to reconstruct a sequence of events from prose. It is archaeology when you should be doing surgery.
Structured logging — logs emitted as JSON with consistent fields — changes this completely. Every log entry is queryable. You can filter by user ID, by request ID, by service, by severity. You can find every log entry associated with a specific failed transaction in seconds. Let me show you how to set it up correctly.
The Core Principle: Logs Are Data
Stop thinking of logs as messages for humans and start thinking of them as data for machines. A structured log entry looks like this:
{
"timestamp": "2026-03-03T14:22:31.456Z",
"level": "error",
"message": "Payment processing failed",
"requestId": "req_7f3a9b2c",
"userId": "usr_12345",
"orderId": "ord_98765",
"provider": "stripe",
"errorCode": "card_declined",
"durationMs": 342,
"service": "payment-api",
"environment": "production"
}
Every field is a dimension you can filter on. "Show me all payment failures for user usr_12345 in the last hour" becomes a one-line query. "Show me all requests that took over 500ms" is trivial. "Correlate this error across the API service and the background job service" is possible because every log entry carries the same requestId.
Pino: The Right Logger for Node.js
If you are building Node.js applications, use Pino. It is an order of magnitude faster than Winston for JSON serialization, which matters when you are logging hundreds of requests per second. It emits structured JSON by default. It has a clean API.
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: {
service: "payment-api",
environment: process.env.NODE_ENV,
version: process.env.APP_VERSION,
},
timestamp: pino.stdTimeFunctions.isoTime,
// In development, pretty-print for readability
...(process.env.NODE_ENV === "development" && {
transport: {
target: "pino-pretty",
options: { colorize: true },
},
}),
});
The base object adds fields to every log entry automatically. You should always include service name, environment, and version. When you are debugging a production incident at 2am and logs from six services are streaming by, knowing which service emitted which log is essential.
Request Logging with Correlation IDs
Every HTTP request should get a unique ID that propagates through every log entry generated during that request. This is the correlation ID pattern, and it is foundational for distributed system debugging.
import { Request, Response, NextFunction } from "express";
import { randomUUID } from "crypto";
import { logger } from "./logger";
import { AsyncLocalStorage } from "async_hooks";
const requestContext = new AsyncLocalStorage<{ requestId: string }>();
export function requestLoggingMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const requestId = req.headers["x-request-id"] as string ?? randomUUID();
const start = Date.now();
// Store in AsyncLocalStorage so any code in this request can access it
requestContext.run({ requestId }, () => {
res.setHeader("x-request-id", requestId);
const requestLogger = logger.child({ requestId, method: req.method, path: req.path });
requestLogger.info("Request started");
res.on("finish", () => {
requestLogger.info({
statusCode: res.statusCode,
durationMs: Date.now() - start,
}, "Request completed");
});
next();
});
}
// Helper to get current request ID from anywhere in your code
export function getRequestId(): string | undefined {
return requestContext.getStore()?.requestId;
}
The AsyncLocalStorage approach lets you access the request ID from anywhere in your application — service classes, database utilities, downstream HTTP clients — without threading it through every function call as a parameter. Log entries from database queries automatically carry the request ID of the HTTP request that triggered them.
When your frontend or API gateway sends a x-request-id header, respect it. This propagates the correlation ID across service boundaries. A user's browser generates a request ID. Your API preserves it. Your background job picked up from the API carries the same ID. You can trace a single user action across your entire system.
Log Levels Done Right
Five log levels. Use them correctly.
error — something failed and requires attention. An unhandled exception, a database query failure, a payment that could not be processed. On-call engineers should see these.
warn — something unusual happened but the request succeeded. A rate limit was hit and the retry succeeded. A circuit breaker opened but fell back gracefully. Worth knowing about but not waking anyone up for.
info — normal operational events worth recording. Request started, request completed, background job finished, user authenticated. Your standard operational log volume.
debug — detailed diagnostic information useful when investigating a specific problem. Database query plans, middleware processing steps, external API response details. This should be off in production by default (set LOG_LEVEL=info) and toggled on when you need deep diagnosis.
trace — extremely verbose. Individual function calls, loop iterations. Almost never appropriate in production.
The mistake I see most often is using error level for expected business logic failures. A user submitting a form with invalid data is not an error — it is expected application behavior. Log it at info level. Reserve error for conditions that represent genuine failures in your system.
Sensitive Data in Logs
Never log passwords, authentication tokens, credit card numbers, or personal data like Social Security numbers. This seems obvious, but I have seen production log streams with JWT tokens in request headers, full credit card numbers in payment request bodies, and passwords in authentication failure messages.
Define a redaction strategy. Pino supports redact paths:
const logger = pino({
redact: {
paths: [
"req.headers.authorization",
"req.body.password",
"req.body.creditCard",
"*.ssn",
],
censor: "[REDACTED]",
},
});
Beyond automatic redaction, establish a culture where developers actively consider what they are logging. Code review should include log output review. A log statement that says logger.info({ user }, "User logged in") is logging the entire user object — potentially including fields that should not be in logs.
Shipping Logs to a Backend
Console output is sufficient for local development. In production, you need logs shipped to a searchable backend with retention and alerting.
For small to medium applications, I recommend Axiom. It is extremely affordable (generous free tier), has a fast query interface, and the ingestion pipeline is simple — ship JSON via HTTP or use their Node.js library. Setup takes thirty minutes.
For larger applications or teams already in AWS, CloudWatch Logs with Log Insights works well. For Kubernetes environments, Grafana Loki with the Promtail agent is the standard open-source stack.
Configure your deployment to pipe stdout to your logging agent. Containers should log to stdout/stderr — not to files. Your container orchestrator or logging agent handles shipping. This keeps your application code ignorant of the logging infrastructure.
The Logging Checklist
Before you ship a new service to production, verify: all log entries are valid JSON, every entry has a timestamp and log level, request logs carry a correlation ID, sensitive fields are redacted, log level is configurable via environment variable, logs are shipping to your backend and queryable, and you have at least one dashboard or saved query that shows error rate from logs.
Structured logging is a small investment that pays off enormously when you need it. And you will need it. Every production system eventually has an incident where you need to understand exactly what happened. Make sure you can.
If you are setting up logging infrastructure for a production application and want to get it right from the start, book a session at https://calendly.com/jamesrossjr.