JSON Structured Logging: Pino, Winston, Correlation IDs & OpenTelemetry
Last updated:
JSON structured logging outputs each log entry as a single-line JSON object — enabling log aggregation tools (Datadog, Splunk, CloudWatch) to parse, filter, and alert on any field without regex. Pino is the fastest Node.js JSON logger at 50,000 logs/second with zero-allocation hot paths; Winston processes about 5,000 logs/second but offers richer transport plugins. Both emit NDJSON (newline-delimited JSON) to stdout by default.
This guide covers Pino and Winston setup, log level configuration (trace/debug/info/warn/error/fatal), request correlation IDs for distributed tracing, redacting sensitive fields (passwords, tokens), and the OpenTelemetry JSON log format. Every example includes TypeScript types.
Why JSON Structured Logging: Benefits Over Plain Text Logs
Plain text logs require brittle regex to extract data after the fact. JSON structured logging exposes every field natively — log aggregation platforms index each key-value pair and enable typed queries like latencyMs > 500 AND statusCode = 200 with no parsing overhead. The operational difference is profound: a plain text search on 1 GB of logs takes minutes; an indexed JSON field query takes milliseconds.
// Plain text log — requires regex to extract latency and status
// "2026-05-20T10:00:00Z INFO request handled latency=42ms status=200 user=alice"
// JSON structured log — every field queryable by name
{"level":"info","msg":"request handled","latencyMs":42,"statusCode":200,"userId":"alice","requestId":"req-7f3a","ts":1716182400000}
// NDJSON stream — each line is a complete JSON object, one per \n
{"level":"info","msg":"server started","pid":12345,"ts":1716182400000}
{"level":"info","msg":"request handled","latencyMs":42,"statusCode":200,"requestId":"req-001","ts":1716182400001}
{"level":"error","msg":"database query failed","err":{"message":"timeout","code":"ETIMEDOUT"},"requestId":"req-002","ts":1716182400002}
{"level":"info","msg":"request handled","latencyMs":8,"statusCode":404,"requestId":"req-003","ts":1716182400003}
// Datadog / CloudWatch Logs Insights query on JSON fields
// fields @timestamp, msg, latencyMs, statusCode, requestId
// | filter latencyMs > 500
// | filter statusCode = 200
// | sort @timestamp desc
// | limit 20
// jq — filter NDJSON locally by field value
// cat app.log | jq 'select(.level == "error")'
// cat app.log | jq 'select(.latencyMs > 500) | {ts, requestId, latencyMs}'
// Minimum required JSON log fields
type LogEntry = {
level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' // or Pino numeric
msg: string // human-readable event description
ts: number // Unix milliseconds (Pino default) or ISO 8601 string
pid: number // process ID — identifies which instance emitted the log
// Recommended additional fields:
requestId?: string // unique per HTTP request
traceId?: string // distributed trace ID
spanId?: string // current span ID
userId?: string // authenticated user
service?: string // which service / component
}JSON log lines are 2–4× larger than equivalent plain text, but modern log platforms compress NDJSON efficiently (60–80% reduction with gzip), and the query performance advantage (10–100× faster indexed field access vs full-text search) makes the trade-off favorable for any service handling more than a few hundred requests per second. See also: JSON performance for serialization benchmarks.
Pino: High-Performance JSON Logging for Node.js
Pino achieves ~50,000 logs/second by delegating transport I/O to a worker thread (pino-worker) and using fast-json-stringify (schema-based, 2× faster than JSON.stringify) for serialization. The main thread only constructs a minimal log object and passes it to the worker — I/O never blocks the event loop. Child loggers propagate request context with zero per-call overhead.
import pino from 'pino'
// ── Basic Pino setup ─────────────────────────────────────────────
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// ISO 8601 timestamps (default is Unix ms — faster to serialize)
timestamp: pino.stdTimeFunctions.isoTime,
// Redact sensitive fields at serialization time
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'body.password'],
censor: '[REDACTED]',
},
// Serialize Error objects with message + stack + code
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
})
// ── Child logger — inject request context ────────────────────────
// All log lines from reqLogger include requestId and userId automatically
import type { Request } from 'express'
function createRequestLogger(req: Request) {
return logger.child({
requestId: req.headers['x-request-id'] as string ?? crypto.randomUUID(),
userId: (req as { user?: { id: string } }).user?.id,
})
}
// ── pino-http — automatic request/response logging for Express ───
import pinoHttp from 'pino-http'
import express from 'express'
const app = express()
app.use(pinoHttp({
logger,
customLogLevel: (_req, res, err) => {
if (err || res.statusCode >= 500) return 'error'
if (res.statusCode >= 400) return 'warn'
return 'info'
},
customSuccessMessage: (req, res) =>
`${req.method} ${req.url} ${res.statusCode}`,
}))
// req.log is a pre-configured child logger per request
app.get('/api/users/:id', (req, res) => {
req.log.info({ userId: req.params.id }, 'fetching user')
res.json({ id: req.params.id })
})
// ── Production: write NDJSON to file via worker thread ──────────
const prodLogger = pino({
level: 'info',
transport: {
target: 'pino/file',
options: { destination: '/var/log/app/app.log', mkdir: true },
},
})
// ── Development: pretty-print (NOT for production — 3x slower) ──
const devLogger = pino({
transport: {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'SYS:standard' },
},
})
// ── Multi-destination: stdout + file simultaneously ───────────────
import { multistream } from 'pino-multi-stream'
import fs from 'fs'
const streams = [
{ stream: process.stdout },
{ stream: fs.createWriteStream('/var/log/app/app.log', { flags: 'a' }) },
]
const multiLogger = pino({ level: 'info' }, multistream(streams))Never use pino-pretty in production — it parses and reformats each JSON line synchronously on the main thread, reducing throughput by ~3×. In production, write raw NDJSON and use your log aggregation platform's UI for human-readable rendering. For TypeScript projects, install @types/pino or use Pino's built-in types (included since Pino v7). See JSON API design for structuring request/response fields.
Winston: Flexible JSON Logging with Transports
Winston is the most widely deployed Node.js logger, with a composable format pipeline and dozens of community transports. To emit JSON logs, combine timestamp(), errors({ stack: true }), and json() in a winston.format.combine()call. Winston is ~10× slower than Pino for JSON output, but its transport ecosystem (Console, File, HTTP, Elasticsearch, Datadog, Splunk) makes it the practical choice in many existing codebases.
import winston from 'winston'
// ── Basic Winston JSON logger ─────────────────────────────────────
const logger = winston.createLogger({
level: process.env.LOG_LEVEL ?? 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
winston.format.errors({ stack: true }), // REQUIRED: serialize Error.stack
winston.format.json(), // emit as JSON string
),
defaultMeta: {
service: process.env.SERVICE_NAME ?? 'app',
version: process.env.npm_package_version,
},
transports: [
new winston.transports.Console(),
new winston.transports.File({
filename: '/var/log/app/app.log',
maxsize: 50 * 1024 * 1024, // 50 MB
maxFiles: 5,
tailable: true,
}),
],
})
// ── Child logger — request context ───────────────────────────────
function createRequestLogger(requestId: string, userId?: string) {
return logger.child({ requestId, userId })
}
// Usage:
// const reqLogger = createRequestLogger(req.headers['x-request-id'] as string)
// reqLogger.info({ method: req.method, url: req.url }, 'request received')
// Output: {"level":"info","message":"request received","requestId":"req-001",
// "method":"GET","url":"/api/users","service":"app","timestamp":"..."}
// ── Custom redaction format ────────────────────────────────────────
const redactFormat = winston.format((info) => {
// Delete sensitive fields before json() serializes them
if (info.password) delete (info as Record<string, unknown>).password
if (info.authorization) delete (info as Record<string, unknown>).authorization
if (info.token) delete (info as Record<string, unknown>).token
return info
})
const secureLogger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
redactFormat(), // redact BEFORE json()
winston.format.errors({ stack: true }),
winston.format.json(),
),
transports: [new winston.transports.Console()],
})
// ── Winston HTTP transport — push logs to an aggregator ──────────
const httpLogger = winston.createLogger({
transports: [
new winston.transports.Http({
host: 'logs.example.com',
port: 443,
ssl: true,
path: '/v1/logs',
}),
],
})
// ── winston-daily-rotate-file — daily log rotation ───────────────
// npm install winston-daily-rotate-file
import 'winston-daily-rotate-file'
const rotatingLogger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
transports: [
new (winston.transports as unknown as {
DailyRotateFile: new (opts: object) => winston.transport
}).DailyRotateFile({
filename: '/var/log/app/app-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxSize: '50m',
maxFiles: '14d',
zippedArchive: true,
}),
],
})The errors({ stack: true }) format is essential and must appear before json() in the combine() chain. Without it, logging an Error object produces {"message":{}} because JSON.stringify omits non-enumerable properties like message and stack. Winston's defaultMeta option is the equivalent of Pino's base option — it injects fields into every log line automatically. See JSON error handling for serializing error objects correctly.
Log Levels and When to Use Each
Log levels act as a severity filter: only events at or above the configured level are emitted. Pino represents levels as integers internally (trace=10, debug=20, info=30, warn=40, error=50, fatal=60) and exposes them as strings in the JSON output. Setting the wrong level is the most common logging mistake — logging at debug in production saturates storage; logging at warn misses operational events that are needed for debugging.
import pino from 'pino'
// ── Pino log levels — numeric values ─────────────────────────────
// trace=10 debug=20 info=30 warn=40 error=50 fatal=60
// Filter: emit if event.level >= logger.level (numeric comparison)
const logger = pino({ level: 'info' })
// Emits: info(30), warn(40), error(50), fatal(60)
// Suppresses: trace(10), debug(20)
// ── When to use each level ───────────────────────────────────────
// TRACE — method entry/exit, loop iterations, detailed variable values
// Use for: performance profiling, algorithm tracing
// Never in production: produces gigabytes of output per minute
logger.trace({ loopIteration: i, item }, 'processing item')
// DEBUG — diagnostic information useful during development
// Use for: SQL queries, HTTP requests to external services, cache decisions
// Enable temporarily in production to diagnose specific issues
logger.debug({ query, params }, 'executing database query')
// INFO — normal operational events (default production level)
// Use for: server started, request handled, job completed, config loaded
logger.info({ method: req.method, url: req.url, statusCode: 200 }, 'request handled')
// WARN — unexpected but recoverable situations
// Use for: slow queries, retries, deprecated API used, config fallback applied
logger.warn({ latencyMs: 1200, threshold: 1000 }, 'slow database query — exceeds threshold')
// ERROR — failure affecting the current operation (not the whole service)
// Use for: database query failed, external API returned 5xx, validation error
logger.error({ err, requestId: req.id }, 'database query failed')
// FATAL — critical failure causing service shutdown
// Use for: cannot bind to port, database unreachable at startup
logger.fatal({ err }, 'cannot connect to database — shutting down')
process.exit(1)
// ── Runtime level change — no restart required ───────────────────
logger.level = 'debug' // immediately enable debug logs
logger.level = 'info' // revert — takes effect on next log call
// Expose a protected internal endpoint for runtime level adjustment
app.post('/internal/log-level', (req, res) => {
const { level } = req.body as { level: pino.LevelWithSilent }
if (!['trace','debug','info','warn','error','fatal','silent'].includes(level)) {
return res.status(400).json({ error: 'invalid level' })
}
logger.level = level
res.json({ level: logger.level })
})
// ── Winston log levels — string comparison ────────────────────────
// error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6
import winston from 'winston'
const wLogger = winston.createLogger({ level: 'info' })
// Emits: error(0), warn(1), info(2)
// Suppresses: http(3), verbose(4), debug(5), silly(6)
// Custom Winston level for HTTP request logs
const customLevels = {
levels: { fatal: 0, error: 1, warn: 2, info: 3, http: 4, debug: 5, trace: 6 },
colors: { fatal: 'red', error: 'red', warn: 'yellow', info: 'green', http: 'cyan', debug: 'blue', trace: 'gray' },
}
winston.addColors(customLevels.colors)Production recommendation: run at info for normal operation. Enable debug temporarily for troubleshooting via a protected runtime endpoint — not a deployment. Always log at error or fatal for exceptions; never swallow errors silently. Use warn for anything that requires investigation but is not immediately breaking. See JSON security for hardening what gets logged at each level.
Request Correlation IDs for Distributed Tracing
A correlation ID (requestId, traceId) is a unique identifier injected into every log line for a single HTTP request — including log lines from database queries, cache operations, and downstream API calls triggered by that request. With correlation IDs, filtering requestId = "req-7f3a" in Datadog returns the complete lifecycle of one request across all services.
import pino from 'pino'
import { AsyncLocalStorage } from 'async_hooks'
import { trace } from '@opentelemetry/api'
import type { Request, Response, NextFunction } from 'express'
const logger = pino({ level: 'info' })
// ── Pattern 1: Child logger passed explicitly ─────────────────────
// Simple but requires passing reqLogger to every function
app.use((req: Request, _res: Response, next: NextFunction) => {
const requestId =
(req.headers['x-request-id'] as string) ?? crypto.randomUUID()
// Attach child logger — includes requestId in every log call
;(req as { log: pino.Logger }).log = logger.child({ requestId })
next()
})
app.get('/api/orders', (req: Request, res: Response) => {
const reqLogger = (req as { log: pino.Logger }).log
reqLogger.info({ userId: req.query.userId }, 'fetching orders')
// All downstream calls receive reqLogger and log with requestId automatically
})
// ── Pattern 2: AsyncLocalStorage — propagate without explicit passing ─
interface RequestContext {
requestId: string
traceId?: string
spanId?: string
userId?: string
}
const contextStore = new AsyncLocalStorage<RequestContext>()
// Middleware: set context once at request boundary
app.use((req: Request, _res: Response, next: NextFunction) => {
const span = trace.getActiveSpan()
const spanCtx = span?.spanContext()
const ctx: RequestContext = {
requestId:
(req.headers['x-request-id'] as string) ?? crypto.randomUUID(),
traceId: spanCtx?.traceId,
spanId: spanCtx?.spanId,
userId: (req as { user?: { id: string } }).user?.id,
}
// run() binds ctx to the async execution context for this request
contextStore.run(ctx, next)
})
// Helper: get a context-aware logger from anywhere in the call stack
function getLogger(): pino.Logger {
const ctx = contextStore.getStore()
return ctx ? logger.child(ctx) : logger
}
// Works anywhere in the async call stack — no logger parameter needed
async function fetchOrdersFromDB(userId: string) {
const log = getLogger() // automatically has requestId, traceId, spanId
log.debug({ userId }, 'querying orders from database')
// log line output:
// {"level":20,"msg":"querying orders from database",
// "requestId":"req-7f3a","traceId":"4bf92f35...","userId":"usr-9k2m",
// "ts":1716182400001}
}
// ── Pattern 3: Forward correlation ID to downstream services ─────
// W3C TraceContext — propagate traceId across HTTP service boundaries
// traceparent header format: 00-{traceId}-{parentSpanId}-{flags}
function injectTraceContext(headers: Headers): void {
const ctx = contextStore.getStore()
if (!ctx?.traceId) return
const traceFlags = '01' // sampled
const parentSpanId = ctx.spanId ?? '0000000000000000'
headers.set('traceparent', `00-${ctx.traceId}-${parentSpanId}-${traceFlags}`)
}
async function callDownstreamService(url: string) {
const log = getLogger()
const headers = new Headers({ 'Content-Type': 'application/json' })
injectTraceContext(headers) // propagate traceId to downstream service
log.info({ url }, 'calling downstream service')
return fetch(url, { headers })
}AsyncLocalStorage is the recommended pattern for greenfield Node.js services — it propagates context across await, setTimeout, and EventEmitter boundaries without explicit parameter passing. It has been stable since Node.js 16 and adds <1 ms overhead per request. When using OpenTelemetry, dd-trace (Datadog), or similar APM libraries, they automatically create spans and populate traceId/spanId in the active context — retrieve them with trace.getActiveSpan()?.spanContext().
Redacting Sensitive Fields from JSON Logs
Redaction must happen at serialization time — before the JSON string is written to any transport, file, or network destination. Downstream redaction (deleting from a log file, scrubbing from an aggregation platform) is unreliable and often incomplete. Pino's redact option is the gold standard: it uses fast-redact under the hood to compile field paths into optimized getter/setter pairs at startup, adding near-zero overhead per log call.
import pino from 'pino'
// ── Pino redact — strip sensitive fields at serialization time ────
const logger = pino({
level: 'info',
redact: {
paths: [
// HTTP headers
'req.headers.authorization', // Bearer tokens, Basic auth
'req.headers.cookie', // session cookies
'req.headers["x-api-key"]', // API key headers
// Request body fields
'body.password',
'body.confirmPassword',
'body.currentPassword',
'body.creditCard',
'body.cardNumber',
'body.cvv',
'body.ssn',
// User object fields
'user.ssn',
'user.dateOfBirth',
// Array wildcard — redact in every array element
'users[*].password',
'users[*].ssn',
'items[*].creditCard',
],
censor: '[REDACTED]', // replacement value (default: "[Redacted]")
// Use remove: true to omit the field entirely:
// remove: true,
// Dynamic censor function:
// censor: (value, path) => {
// if (path.join('.').includes('password')) return '***'
// return '[REMOVED]'
// },
},
})
// ── Result: sensitive fields replaced before writing ─────────────
logger.info({
req: {
headers: { authorization: 'Bearer sk-prod-abc123', cookie: 'session=xyz' },
body: { username: 'alice', password: 'secret123', creditCard: '4111111111111111' },
},
user: { id: 'usr-9k2m', ssn: '123-45-6789', name: 'Alice' },
}, 'user login attempt')
// Output:
// {"level":30,"msg":"user login attempt",
// "req":{"headers":{"authorization":"[REDACTED]","cookie":"[REDACTED]"},
// "body":{"username":"alice","password":"[REDACTED]","creditCard":"[REDACTED]"}},
// "user":{"id":"usr-9k2m","ssn":"[REDACTED]","name":"Alice"},"ts":...}
// ── Winston redaction — custom format function ────────────────────
import winston from 'winston'
const SENSITIVE_FIELDS = new Set(['password','token','authorization','cookie',
'creditCard','cardNumber','cvv','ssn','secret'])
// Recursive redaction for nested objects
function redactDeep(obj: unknown, depth = 0): unknown {
if (depth > 10 || obj === null || typeof obj !== 'object') return obj
if (Array.isArray(obj)) return obj.map(item => redactDeep(item, depth + 1))
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = SENSITIVE_FIELDS.has(key.toLowerCase())
? '[REDACTED]'
: redactDeep(value, depth + 1)
}
return result
}
const redactFormat = winston.format((info) => {
return redactDeep(info) as winston.Logform.TransformableInfo
})
const wLogger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
redactFormat(), // redact BEFORE json() serializes
winston.format.errors({ stack: true }),
winston.format.json(),
),
transports: [new winston.transports.Console()],
})
// ── Allowlist approach — log only pre-approved fields ────────────
// Safer than a denylist: new sensitive fields are not logged by accident
const SAFE_REQUEST_FIELDS = ['method', 'url', 'statusCode', 'latencyMs', 'contentType']
function sanitizeRequest(req: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(req).filter(([k]) => SAFE_REQUEST_FIELDS.includes(k))
)
}Prefer an allowlist over a denylist for request logging: define exactly which fields are safe to log and include only those. A denylist misses new sensitive fields added to the request body in future API changes. Common categories to always redact: authentication credentials (Authorization headers, API keys, OAuth tokens), session identifiers (cookies, session tokens), personal identifiable information (SSNs, dates of birth, full credit card numbers), and health information. See JSON security for a comprehensive treatment of sensitive data handling.
OpenTelemetry JSON Log Format Integration
OpenTelemetry (OTel) defines a standard log data model for all observability signals (traces, metrics, logs). The OTel log body field must be a string — structured JSON payloads require JSON.stringify() before assignment. OTel log records carry trace correlation fields (traceId, spanId) alongside severity, timestamp, and resource attributes, enabling unified querying across traces and logs in backends like Grafana Tempo + Loki, Datadog APM, or Jaeger + Elasticsearch.
import { SeverityNumber, logs } from '@opentelemetry/api-logs'
import {
LoggerProvider,
SimpleLogRecordProcessor,
ConsoleLogRecordExporter,
} from '@opentelemetry/sdk-logs'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
import { Resource } from '@opentelemetry/resources'
import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
import { trace, context as otelContext } from '@opentelemetry/api'
// ── OpenTelemetry log provider setup ─────────────────────────────
const resource = new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'users-api',
'service.version': process.env.npm_package_version ?? '1.0.0',
'deployment.environment': process.env.NODE_ENV ?? 'production',
})
const otlpExporter = new OTLPLogExporter({
url: 'http://otel-collector:4318/v1/logs', // OTLP HTTP endpoint
})
const loggerProvider = new LoggerProvider({ resource })
loggerProvider.addLogRecordProcessor(
new SimpleLogRecordProcessor(otlpExporter)
)
logs.setGlobalLoggerProvider(loggerProvider)
// ── OTel logger — emit log records with trace correlation ────────
const otelLogger = logs.getLogger('users-api', '1.0.0')
function emitStructuredLog(
level: SeverityNumber,
message: string,
attributes: Record<string, string | number | boolean>
) {
// Inject active span context for trace correlation
const span = trace.getActiveSpan()
const spanCtx = span?.spanContext()
// CRITICAL: body must be a string — JSON.stringify the payload
const bodyPayload = { message, ...attributes }
otelLogger.emit({
severityNumber: level,
severityText: SeverityNumber[level],
body: JSON.stringify(bodyPayload), // <-- JSON.stringify() required
// Trace correlation fields — link log to active span
...(spanCtx && {
traceId: spanCtx.traceId,
spanId: spanCtx.spanId,
traceFlags: spanCtx.traceFlags,
}),
attributes, // structured key-value pairs (indexed by OTel collector)
timestamp: Date.now(),
})
}
// ── Usage examples ────────────────────────────────────────────────
emitStructuredLog(SeverityNumber.INFO, 'request handled', {
'http.method': 'GET',
'http.url': '/api/users/123',
'http.status_code': 200,
'request.id': 'req-7f3a',
'latency.ms': 42,
})
emitStructuredLog(SeverityNumber.ERROR, 'database query failed', {
'error.type': 'QueryError',
'error.message': 'duplicate key violation',
'db.table': 'users',
'request.id': 'req-7f3a',
})
// ── Pino bridge to OpenTelemetry ──────────────────────────────────
// npm install pino-opentelemetry-transport
// Use as a Pino transport — routes Pino JSON to OTel log provider
import pino from 'pino'
const pinoWithOtel = pino({
level: 'info',
transport: {
target: 'pino-opentelemetry-transport',
options: {
resourceAttributes: {
'service.name': 'users-api',
'deployment.environment': 'production',
},
},
},
})
// ── OTel log severity mapping from Pino/Winston levels ───────────
const PINO_TO_OTEL_SEVERITY: Record<string, SeverityNumber> = {
trace: SeverityNumber.TRACE,
debug: SeverityNumber.DEBUG,
info: SeverityNumber.INFO,
warn: SeverityNumber.WARN,
error: SeverityNumber.ERROR,
fatal: SeverityNumber.FATAL,
}
// ── OTel Collector config (otel-collector-config.yaml) ─────────
// receivers:
// otlp:
// protocols:
// http:
// endpoint: 0.0.0.0:4318
// exporters:
// loki:
// endpoint: http://loki:3100/loki/api/v1/push
// datadog:
// api:
// key: ${DD_API_KEY}
// service:
// pipelines:
// logs:
// receivers: [otlp]
// exporters: [loki, datadog]The most important OTel constraint: the body field of a log record must be a string. Passing a JavaScript object directly produces [object Object] in the exported log. Always JSON.stringify() your structured payload before assigning it to body, and also set the same fields as attributes (key-value pairs that OTel backends index for querying). The pino-opentelemetry-transport handles this conversion automatically for Pino users. See also JSON API design and JSON performance for related topics.
Key Terms
- structured logging
- A logging approach where each log entry is emitted as a structured data object (JSON) with defined key-value fields, rather than a free-form text string. Structured logs are machine-parseable without regex extraction, enabling log aggregation platforms to index individual fields for fast, typed queries. The alternative — unstructured or plain text logging — produces human-readable strings that require brittle regex patterns to extract data for analysis. Structured JSON logging has become the default for cloud-native services because Datadog, Grafana Loki, AWS CloudWatch, and Splunk all support native JSON field indexing and querying.
- NDJSON
- Newline-Delimited JSON (also called JSON Lines or JSONL) is a format where each line is a complete, self-contained JSON object terminated by a newline character (
\n). Log files in NDJSON format are streamable, appendable, and greppable: a log consumer can process each line independently without parsing the entire file. A process crash leaves all previously written lines valid (unlike a JSON array, which requires balanced brackets). NDJSON is the standard output format for Pino, Bunyan, and most cloud-native loggers. Tools likejqprocess NDJSON natively:cat app.log | jq 'select(.level == "error")'. - Pino
- A high-performance Node.js JSON logger achieving ~50,000 log lines/second. Pino's speed advantage comes from delegating transport I/O to a worker thread (
pino-worker) and usingfast-json-stringify(schema-based, 2× faster thanJSON.stringify) for serialization. The main thread only constructs a minimal log object and passes it to the worker — no I/O blocks the event loop. Key features: child loggers for request context propagation, built-inredactoption for sensitive field removal at serialization time, async file transport, andpino-httpfor automatic Express/Fastify request logging. Pino emits NDJSON to stdout by default. - Winston
- The most widely deployed Node.js logger, offering a composable format pipeline and dozens of community transports. Winston's format pipeline is built with
winston.format.combine()chaining individual format functions:timestamp(),errors({ stack: true }),json(),colorize(), and custom format functions. Transport plugins include Console, File, HTTP, Elasticsearch, Datadog, Splunk, and Sentry. Winston processes ~5,000 logs/second — 10× slower than Pino — due to its synchronous format pipeline. It remains popular in older codebases and when the transport ecosystem flexibility outweighs the performance trade-off. - correlation ID
- A unique identifier (requestId, traceId, or correlationId) injected into every log line produced during a single HTTP request or business transaction. With a correlation ID, filtering
requestId = "req-7f3a"in a log aggregation platform returns all log lines from that request — database queries, cache operations, downstream API calls, and response logging — enabling end-to-end request tracing. Correlation IDs are generated at the service boundary (first middleware layer) from the incomingX-Request-IDheader orcrypto.randomUUID(), then propagated to downstream services via the same header. For distributed tracing, the W3Ctraceparentheader carries traceId and spanId across service boundaries. - redaction
- The process of removing or masking sensitive field values from log JSON before the log line is serialized to string and written to any transport or network destination. Redaction must happen at the serialization layer — not downstream — because once a sensitive value appears in a log file or is shipped to a log aggregation platform, complete purging is difficult or impossible. Pino's
redactoption accepts dot-notation field paths (including array wildcards likeusers[*].password) compiled into optimized accessors at startup, adding near-zero overhead per log call. Typical candidates for redaction: Authorization headers, cookies, passwords, API keys, credit card numbers, Social Security numbers, and PII fields subject to GDPR or HIPAA regulations. - log level
- A severity classification for log entries indicating the importance and urgency of the logged event. Standard levels in ascending severity: trace (Pino: 10) — very verbose per-step tracing; debug (20) — diagnostic information; info (30) — normal operational events; warn (40) — unexpected but recoverable; error (50) — operation failure; fatal (60) — service shutdown. The active log level is a filter threshold: Pino compares numeric values, emitting only events where the event level number ≥ the configured threshold number. Log level can be changed at runtime without restart in both Pino (
logger.level = "debug") and Winston (logger.level = "debug"). - OpenTelemetry
- An open-source observability framework providing vendor-neutral APIs and SDKs for traces, metrics, and logs. The OTel log data model defines a standard log record structure:
body(string — JSON payloads must beJSON.stringify()-wrapped),severityNumber(numeric severity mapping to trace/debug/info/warn/error/fatal),traceIdandspanId(trace correlation fields linking logs to active spans),attributes(structured key-value pairs indexed by the OTel Collector), andresourceattributes (service name, version, environment). The OTel Collector receives log records via OTLP and exports them to backends (Grafana Loki, Datadog, Elasticsearch). For Node.js,pino-opentelemetry-transportbridges Pino's output to the OTel log provider automatically.
FAQ
What is JSON structured logging and why use it?
JSON structured logging outputs each log entry as a single-line JSON object — {"level":"info","msg":"request handled","latencyMs":42,"statusCode":200,"requestId":"req-001","ts":1716182400000} — rather than a free-form text string like INFO request handled latency=42ms status=200. Every field has a defined key and typed value, making logs machine-parseable without custom regex. Log aggregation platforms (Datadog, Splunk, CloudWatch, Grafana Loki) can index every JSON field and enable queries like “show all requests where latencyMs > 500 and statusCode = 200” using indexed field access rather than full-text search. Plain text logs require brittle regex extraction to recover structured data after the fact; JSON logs expose it natively. The trade-off is that JSON log lines are 2–4× larger than equivalent plain text, but indexed field queries are 10–100× faster in aggregation systems, and modern platforms compress JSON efficiently (60–80% reduction with gzip).
What is the difference between Pino and Winston for JSON logging?
Pino and Winston are the two dominant Node.js JSON loggers. Performance: Pino logs ~50,000 messages/second by delegating I/O to a worker thread and using schema-based fast-json-stringify; Winston logs ~5,000 messages/second due to its synchronous format pipeline. Format: Pino emits JSON by default with minimal configuration; Winston requires a combine(timestamp(), errors({ stack: true }), json()) format chain. Transports: Winston has dozens of community transports (Elasticsearch, Splunk, Sentry, Slack); Pino uses pino-transports and community packages. Redaction: Pino has a built-in redact option at serialization time; Winston requires a custom format function. Child loggers: both support logger.child({ requestId }) for request-scoped context. Recommendation: use Pino for greenfield Node.js and Next.js services; use Winston when migrating from an existing Winston codebase or when specific transport plugins are required.
How do I set up Pino for JSON logging in Node.js?
Install Pino: npm install pino pino-http. Create a logger: const logger = pino({ level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization', 'body.password'] }). For Express, add app.use(pinoHttp({ logger }))) — each request gets a child logger at req.log with requestId auto-injected. Use child loggers for request context: const reqLogger = logger.child({ requestId, userId }). For development, pipe stdout through pino-pretty: node server.js | pino-pretty. For production, configure a file transport: pino({ transport: { target: 'pino/file', options: { destination: '/var/log/app.log' } } }). For TypeScript, Pino types are bundled — no separate @types/pino needed since v7. Never use pino-prettyin production — it reduces throughput by 3× because it parses and reformats each JSON line synchronously on the main thread.
How do I add correlation IDs to JSON log entries?
Two patterns. Pattern 1 — child logger: at the start of each request, create a child logger and attach it to the request object: req.log = logger.child({ requestId: req.headers['x-request-id'] ?? crypto.randomUUID(), userId: req.user?.id }). Every log call from req.log or loggers derived from it automatically includes requestId and userId. Pattern 2 — AsyncLocalStorage: store the request context in a Node.js AsyncLocalStorage instance at the middleware layer, then call logger.child(contextStore.getStore()) from any async function in the call stack without passing a logger parameter. AsyncLocalStorage propagates the context across await, setTimeout, and EventEmitter boundaries. For distributed tracing, also inject traceId and spanId from the active OTel span: trace.getActiveSpan()?.spanContext(). Forward the correlation ID to downstream services via the X-Request-ID and W3C traceparent headers.
How do I redact passwords and tokens from JSON logs?
Pino provides a built-in redact option that removes or masks fields at serialization time — before the JSON string is written to any transport: pino({ redact: { paths: ['req.headers.authorization', 'req.headers.cookie', 'body.password', 'users[*].ssn'], censor: '[REDACTED]' } }). Use array wildcard paths (users[*].password) to redact fields in every array element. Use remove: true to omit the field entirely instead of replacing with a censor string. For Winston, implement a custom format function: winston.format(info => { delete info.password; return info; })() placed before json()in the format chain. Critical rule: never log Authorization headers, cookies, passwords, API keys, or credit card numbers, even in debug-level logs — log level can be changed at runtime. Prefer an allowlist approach: enumerate exactly which fields are safe to log and include only those, rather than denylisting known sensitive fields.
What log levels should I use for JSON logging?
Standard log levels in ascending severity: trace (Pino: 10) — per-step method tracing, loop iterations; never in production as it generates gigabytes per minute. debug (20) — SQL queries, external API calls, cache decisions; enable temporarily in production to diagnose issues. info (30) — normal operational events: request handled, server started, job completed; the recommended production level. warn (40) — slow queries, retries, deprecated API calls, config fallbacks applied. error (50) — operation failures: database query failed, external API returned 5xx; always log with the err object. fatal (60) — critical failures causing service shutdown: cannot bind to port, database unreachable. In Pino, change the level at runtime without restart: logger.level = 'debug'. Expose a protected internal endpoint for runtime level adjustment; never swallow exceptions silently — always log at error or fatal.
How do I send JSON logs to Datadog or CloudWatch?
For Datadog: write NDJSON to a file, configure the Datadog Agent to tail it (conf.d/node.d/conf.yaml with type: file, source: nodejs). Add dd.service, dd.env, and dd.version fields to every log line for Datadog facets. For automatic trace correlation, initialize dd-trace before any other import — it injects dd.trace_id and dd.span_id into Pino and Winston log lines automatically. For AWS CloudWatch: use @aws-sdk/client-cloudwatch-logs PutLogEventsCommand with JSON strings as logEvents[].message. CloudWatch Logs Insights queries JSON fields: fields latencyMs | filter latencyMs > 1000 | sort @timestamp desc. For Grafana Loki: deploy promtail to tail log files, or push directly via POST /loki/api/v1/push. Keep Loki stream label cardinality low — use only app, env, level as labels; query high-cardinality fields (requestId, userId) via LogQL's | json pipeline.
What is NDJSON and why do loggers use it?
NDJSON (Newline-Delimited JSON, also called JSON Lines or JSONL) is a format where each line is a complete, self-contained JSON object terminated by a newline character (\n). Loggers use NDJSON because it is streamable — a log consumer (log agent, grep, jq) can process each line independently without parsing the entire file as a JSON array. A streaming file write never produces invalid JSON: even if the process crashes mid-write, all previously written lines remain valid. Standard JSON arrays require balanced brackets — a crash leaves the file as invalid JSON. NDJSON files are also appendable (multiple processes can append without coordination), greppable (grep "error" app.log filters instantly), and processable with jq: cat app.log | jq 'select(.level == "error")' extracts all error entries. All major log aggregation platforms (Datadog, Loki, CloudWatch, Splunk) accept NDJSON as their primary log format.
Further reading and primary sources
- Pino Documentation — Official Pino logger docs — configuration, child loggers, transports, redaction, and serializers
- Winston on GitHub — Winston logger documentation covering formats, transports, and custom format composition
- OpenTelemetry Logs SDK for Node.js — Official OTel JavaScript docs for the logs signal — LoggerProvider, emitting log records, OTLP export
- Grafana Loki LogQL Documentation — LogQL query language reference including JSON parsing pipelines and label filtering
- Datadog Log Collection for Node.js — Datadog guide for collecting and parsing Node.js JSON logs with dd-trace integration