What Is Structured Logging and Why Does It Matter
Structured logging means emitting log data as machine-parseable records (typically JSON) rather than human-readable strings. Every log entry has a consistent schema: a timestamp, a severity level, a service name, a trace ID for correlation, and a message — plus any additional context fields relevant to that specific event.
The difference becomes apparent when you need to answer operational questions at scale. With unstructured logs, 'how many users had failed payments in the last 24 hours?' requires regex parsing of free-form text across potentially billions of log lines — slow, fragile, and error-prone. With structured logs, it's a single indexed query: level=error AND event=payment.failed, aggregated by userId, limited to the last 24 hours — milliseconds.
Structured logging is also the foundation for automated observability. AI-powered anomaly detection, log clustering, and root cause analysis all operate on structured fields. An LLM analyzing a structured error event extracts far more signal than an LLM analyzing unformatted console output.
Why console.log Is a Production Risk
console.log() outputs to stdout with no timestamp, no structured format, and no log level. In production, these limitations compound: you cannot filter by severity, you cannot index fields, you cannot correlate logs across services, and you cannot reliably extract structured data from arbitrary strings. console.log() in production is like writing your database schema in notepad files.
Beyond the query limitations, console.log() has a performance problem: it is synchronous and blocks the Node.js event loop while writing. In high-throughput applications, this can add measurable latency to every request that triggers logging. Production logging requires asynchronous, non-blocking I/O.
- No timestamp: logs arrive in stdout without accurate timing
- No log level: cannot filter by severity in log search
- No structure: cannot query fields — only full-text search
- Synchronous: blocks the event loop under high concurrency
- No correlation: cannot connect logs from the same request across services
Setting Up Pino: The Right Logging Library for Node.js
Pino is the fastest Node.js logging library, emitting JSON logs with minimal overhead. It is non-blocking, supports child loggers for contextual logging, integrates natively with Express and Fastify, and ships TypeScript types.
npm install pino pino-http
npm install --save-dev pino-pretty # human-readable output in developmentLogger Configuration for Production
Configure Pino once at the application root and export a singleton logger. All modules import this logger rather than creating their own — this ensures consistent log format and allows the base context (service name, version, environment) to be injected into every log entry automatically.
// logger.ts — configure once, import everywhere
import pino from 'pino';
const isDev = process.env.NODE_ENV !== 'production';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// In development: pretty-print for human readability
// In production: raw JSON for machine parsing
transport: isDev
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
// Base fields included in every log line
base: {
service: process.env.SERVICE_NAME ?? 'api',
version: process.env.npm_package_version ?? '0.0.0',
env: process.env.NODE_ENV ?? 'development',
},
// Redact sensitive fields before logging — PII compliance
redact: {
paths: ['*.password', '*.token', '*.authorization', '*.credit_card', '*.ssn'],
censor: '[REDACTED]',
},
// ISO timestamps in production
timestamp: pino.stdTimeFunctions.isoTime,
});
// Usage:
logger.info({ event: 'server.started', port: 3000 }, 'HTTP server listening');
logger.error({ event: 'db.connection_failed', err }, 'MongoDB connection failed');Correlation IDs: Connecting Logs Across Services
A correlation ID (also called a request ID or trace ID) is a unique identifier generated at the entry point of a request and propagated through every service and log call that request triggers. When debugging an incident, you search for the correlation ID and see every log line from every service that handled that specific request — in order, with full context.
With OpenTelemetry enabled, the trace ID serves as your correlation ID and is automatically propagated. Without OTel, you generate a UUID at the request entry point, store it in AsyncLocalStorage, and include it in every log call via a child logger bound to the request context.
// Request correlation with AsyncLocalStorage
import { AsyncLocalStorage } from 'async_hooks';
import { randomUUID } from 'crypto';
import { logger } from './logger';
const requestContext = new AsyncLocalStorage<{ requestId: string; userId?: string }>();
// Express middleware — runs on every request
export function correlationMiddleware(req: Request, res: Response, next: NextFunction) {
const requestId = req.headers['x-request-id'] as string ?? randomUUID();
res.setHeader('x-request-id', requestId);
// Store request context — available in all async calls from this request
requestContext.run({ requestId }, () => {
next();
});
}
// Get a request-scoped child logger — includes requestId automatically
export function getLogger() {
const ctx = requestContext.getStore();
if (!ctx) return logger;
return logger.child({ requestId: ctx.requestId, userId: ctx.userId });
}
// In your route handlers:
app.post('/api/checkout', async (req, res) => {
const log = getLogger();
log.info({ event: 'checkout.started', itemCount: req.body.items.length }, 'Checkout initiated');
// All logs from this request share the same requestId — searchable together
});Log Levels: Using Them Correctly
Log levels are not just labels — they are filters. In production, most applications should run at info level, which means trace and debug messages are not written. If you log everything at info or above without discipline, you create the same signal-to-noise problem as having no levels at all. The level should communicate actionability.
- trace — Development only. Verbose execution steps. Never in production.
- debug — Development and troubleshooting. Detailed state. Disable in production by default.
- info — Normal operations. Request received, action completed, resource created. High volume but expected.
- warn — Unexpected but handled. Retry succeeded, fallback triggered, rate limit approached.
- error — Something failed. Requires investigation. Must include the error object and full context.
- fatal — Service cannot continue. Database unreachable, required config missing. Triggers immediate alert.
Stop debugging production in the dark
ObservabilityOS gives every engineer AI-powered incident intelligence. Zero config. Connects in 5 minutes.
About the Author
ObservabilityOS Team
Core Engineering & DevRel
The core engineering, site reliability, and developer relations team behind ObservabilityOS. We build AI-native observability infrastructure to eliminate 3 AM firefighting.