Learn/Advanced Topics

JSON Structured Logging — Build Observable Systems

Plain text logs are impossible to query at scale. Structured JSON logging transforms your logs into searchable, correlated, machine-parseable data that lets you debug production incidents in minutes instead of hours. This guide covers schema design, library selection, OpenTelemetry integration, and building a complete log pipeline.

Why Plain Text Logs Fail

Consider this production incident: a payment service returns 500 errors for 2% of requests. With unstructured logs, you grep through gigabytes of text hoping to find the pattern. With structured JSON logs, you run a single query:

ApproachQueryTime to Answer
Unstructuredgrep "error" app.log | grep "payment" | grep -c "500"Minutes to hours
Structured JSONlevel:"error" AND service:"payment" AND status:500 | stats count by error_codeSeconds

Unstructured vs Structured

Unstructured log (impossible to query reliably)text
1[2026-04-15 14:30:01] ERROR PaymentService - Payment failed for user 12345: insufficient funds (txn: abc-789)
2[2026-04-15 14:30:01] INFO PaymentService - Retrying payment for user 12345
3[2026-04-15 14:30:02] ERROR PaymentService - Payment retry failed for user 12345: card declined
Structured JSON log (every field is queryable)json
1{"timestamp":"2026-04-15T14:30:01.234Z","level":"error","service":"payment-service","message":"Payment failed","user_id":"12345","txn_id":"abc-789","error_code":"insufficient_funds","amount":99.99,"currency":"USD","trace_id":"4bf92f3577b34da6","request_id":"req_01HX"}
2{"timestamp":"2026-04-15T14:30:01.567Z","level":"info","service":"payment-service","message":"Retrying payment","user_id":"12345","txn_id":"abc-789","retry_attempt":1,"trace_id":"4bf92f3577b34da6","request_id":"req_01HX"}
3{"timestamp":"2026-04-15T14:30:02.012Z","level":"error","service":"payment-service","message":"Payment retry failed","user_id":"12345","txn_id":"abc-789","error_code":"card_declined","retry_attempt":1,"trace_id":"4bf92f3577b34da6","request_id":"req_01HX"}

Designing a JSON Log Schema

Anatomy of a Structured JSON Log Entry
FieldTypePurposeExample
timestampstring (ISO 8601)When the event occurred (UTC)2026-04-15T14:30:01.234Z
levelstringSeverity: debug/info/warn/error/fatalerror
messagestringHuman-readable event descriptionPayment failed
servicestringOriginating service identifierpayment-service
trace_idstringDistributed trace ID (W3C/OpenTelemetry)4bf92f3577b34da6a3ce929d
span_idstringCurrent span within the trace00f067aa0ba902b7
request_idstringUnique request identifierreq_01HXYZ9ABC
environmentstringDeployment environmentproduction
versionstringApplication version2.4.1

Field Naming Convention

Use snake_case for all log field names. This is the de facto standard across observability platforms (Datadog, Elastic, Grafana) and avoids quoting issues in query languages. Stay consistent across all services.

Log Levels Strategy

Define clear semantics for each level across your organization. Ambiguous level usage makes filtering useless:

LevelWhen to UseProduction VolumeAlert?
fatalProcess cannot continue, will exitExtremely rareImmediate page
errorOperation failed, needs investigationLowAlert within minutes
warnUnexpected but handled (retries, fallbacks)ModerateDashboard monitoring
infoSignificant business events (user signup, payment)HighNo
debugDiagnostic detail (query params, cache hits)Very highNever (disabled in prod)
traceFine-grained execution flowExtremeNever (dev only)

Warning

Never use console.log in production Node.js applications. It writes to stdout synchronously, blocking the event loop. Use a proper logging library (pino, winston) that handles serialization asynchronously.

Implementation: Node.js with Pino

Pino is the fastest JSON logger for Node.js (~5x faster than winston) because it serializes on a worker thread and avoids synchronous I/O:

logger.js — Pino setup with base fieldsjavascript
1import pino from 'pino';
2
3const logger = pino({
4 level: process.env.LOG_LEVEL || 'info',
5 timestamp: pino.stdTimeFunctions.isoTime,
6
7 // Redact sensitive fields
8 redact: {
9 paths: [
10 'req.headers.authorization',
11 'req.headers.cookie',
12 'body.password',
13 'body.creditCard',
14 ],
15 censor: '[REDACTED]',
16 },
17
18 // Base fields included in every log entry
19 base: {
20 service: 'payment-service',
21 version: process.env.APP_VERSION || '0.0.0',
22 environment: process.env.NODE_ENV || 'development',
23 },
24
25 // Format for development readability
26 transport: process.env.NODE_ENV !== 'production'
27 ? { target: 'pino-pretty', options: { colorize: true } }
28 : undefined,
29});
30
31export default logger;
Using the logger in an Express routejavascript
1import logger from './logger.js';
2
3app.post('/api/payments', async (req, res) => {
4 const requestLogger = logger.child({
5 request_id: req.headers['x-request-id'],
6 user_id: req.user?.id,
7 });
8
9 requestLogger.info({ amount: req.body.amount }, 'Payment initiated');
10
11 try {
12 const result = await processPayment(req.body);
13 requestLogger.info(
14 { txn_id: result.id, duration_ms: result.duration },
15 'Payment succeeded'
16 );
17 res.json({ success: true, txn_id: result.id });
18 } catch (err) {
19 requestLogger.error(
20 { error_code: err.code, error_message: err.message },
21 'Payment failed'
22 );
23 res.status(500).json({ error: 'Payment processing failed' });
24 }
25});

Express HTTP Request Logging

Automatic HTTP request logging with pino-httpjavascript
1import pinoHttp from 'pino-http';
2import logger from './logger.js';
3
4app.use(pinoHttp({
5 logger,
6 autoLogging: true,
7 customProps: (req) => ({
8 request_id: req.headers['x-request-id'],
9 user_id: req.user?.id,
10 }),
11 serializers: {
12 req: (req) => ({
13 method: req.method,
14 url: req.url,
15 query: req.query,
16 }),
17 res: (res) => ({
18 status_code: res.statusCode,
19 }),
20 },
21}));
22
23// Every request now automatically logs:
24// {"level":"info","timestamp":"...","service":"payment-service",
25// "request_id":"req_01HX","method":"GET","url":"/api/users",
26// "status_code":200,"response_time":45}

Implementation: Python with structlog

structlog configurationpython
1import structlog
2import logging
3
4structlog.configure(
5 processors=[
6 structlog.contextvars.merge_contextvars,
7 structlog.processors.add_log_level,
8 structlog.processors.TimeStamper(fmt="iso"),
9 structlog.processors.StackInfoRenderer(),
10 structlog.processors.format_exc_info,
11 structlog.processors.JSONRenderer(),
12 ],
13 wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
14 context_class=dict,
15 logger_factory=structlog.PrintLoggerFactory(),
16)
17
18log = structlog.get_logger(service="user-service", version="1.2.0")
Using structlog in a FastAPI routepython
1from fastapi import FastAPI, Request
2import structlog
3
4app = FastAPI()
5log = structlog.get_logger()
6
7@app.middleware("http")
8async def logging_middleware(request: Request, call_next):
9 structlog.contextvars.clear_contextvars()
10 structlog.contextvars.bind_contextvars(
11 request_id=request.headers.get("x-request-id", "unknown"),
12 method=request.method,
13 path=request.url.path,
14 )
15 response = await call_next(request)
16 log.info("request_handled", status_code=response.status_code)
17 return response
18
19@app.post("/api/orders")
20async def create_order(order: OrderRequest):
21 log.info("order_created", order_id=order.id, total=order.total)
22 # Every log entry automatically includes request_id, method, path
23 return {"status": "created"}

Correlating Logs with OpenTelemetry

Distributed Trace Correlation
OpenTelemetry + Pino integrationjavascript
1import { trace, context } from '@opentelemetry/api';
2import pino from 'pino';
3
4const logger = pino({
5 mixin() {
6 const span = trace.getSpan(context.active());
7 if (!span) return {};
8 const ctx = span.spanContext();
9 return {
10 trace_id: ctx.traceId,
11 span_id: ctx.spanId,
12 trace_flags: ctx.traceFlags,
13 };
14 },
15});
16
17// Now every log entry automatically includes trace_id and span_id
18// from the active OpenTelemetry context
19logger.info('Processing payment');
20// Output: {"level":"info","trace_id":"4bf92f35...","span_id":"00f067aa...",
21// "message":"Processing payment"}

Note

With trace correlation in place, clicking a slow request in your tracing UI (Jaeger, Tempo, Datadog APM) shows the exact log entries from every service that handled that request. This turns a multi-hour investigation into a 30-second lookup.

Log Pipeline Architecture

JSON Log Pipeline: Application to Dashboard

A production log pipeline has three stages: emit (application writes JSON to stdout),collect (a log collector like Vector or Fluentd aggregates, enriches, and routes logs), and store (a log backend indexes and makes logs queryable).

ComponentOptionsStrengths
Log CollectorVector, Fluentd, Fluent Bit, LogstashVector: Rust-based, low memory; Fluentd: mature ecosystem
Log StorageElasticsearch, Grafana Loki, ClickHouseLoki: label-based (cheap); ES: full-text search (powerful)
DashboardKibana, Grafana, DatadogGrafana: unified metrics+logs+traces; Datadog: managed SaaS
AlertingGrafana Alerting, PagerDuty, OpsGenieDefine alerts on log patterns: error rate > 5% in 5 minutes

Security: What Not to Log

Structured logging makes it easy to log everything — but some data must never appear in logs. A single leaked token in a log file can compromise your entire system:

Never LogWhyAlternative
Passwords or hashesCredential exposureLog authentication result only (success/fail)
Authorization tokensSession hijackingLog token prefix or hash: tok_abc...
Credit card numbersPCI-DSS violationLog last 4 digits only: ****1234
Email addresses, SSNsPrivacy regulation (GDPR)Log anonymized user_id instead
Full request bodiesMay contain any of the aboveLog specific safe fields explicitly
API keys or secretsFull system compromiseLog key prefix or name, never value
Pino redaction configurationjavascript
1const logger = pino({
2 redact: {
3 paths: [
4 'req.headers.authorization',
5 'req.headers.cookie',
6 'body.password',
7 'body.ssn',
8 'body.creditCard',
9 'body.*.token', // nested token fields
10 ],
11 censor: '[REDACTED]',
12 },
13});
14
15// Input: { body: { password: "secret123", username: "alice" } }
16// Output: { body: { password: "[REDACTED]", username: "alice" } }

Analyzing JSON Logs with jq

Production log analysis queriesbash
1# Find all errors in the last hour
2cat app.log | jq 'select(.level == "error")'
3
4# Slow requests (> 1 second)
5cat app.log | jq 'select(.duration_ms > 1000) | {path: .url, duration_ms, timestamp}'
6
7# Error rate by service
8cat app.log | jq -s '
9 group_by(.service) | map({
10 service: .[0].service,
11 total: length,
12 errors: [.[] | select(.level == "error")] | length
13 })'
14
15# Trace a single request across services
16cat app.log | jq 'select(.request_id == "req_01HXYZ9ABC")'
17
18# Top 10 slowest endpoints
19cat app.log | jq -s '
20 [.[] | select(.duration_ms)] |
21 sort_by(-.duration_ms) |
22 .[0:10] |
23 .[] | {url, duration_ms, timestamp}'

Best Practices

  • Use ISO 8601 UTC timestamps (2026-04-15T14:30:01.234Z) for consistent cross-timezone querying
  • Include trace_id and request_id in every log entry for cross-service correlation
  • Standardize field names (snake_case) across all services so queries work everywhere
  • Log to stdout, let the infrastructure (Docker, K8s) handle collection and routing
  • Use child loggers (pino) or context variables (structlog) to attach request-scoped fields
  • Set up log-based alerts: error rate thresholds, specific error codes, latency spikes
  • Never log PII, authentication tokens, or credit card numbers — use redaction
  • Never use console.log in production — it blocks the Node.js event loop
  • Avoid logging entire request/response bodies — they may contain sensitive data and bloat storage

Frequently Asked Questions

What is structured logging?
Structured logging means emitting log entries as machine-parseable data structures (typically JSON) instead of free-form text strings. Each log entry contains named fields like timestamp, level, message, service, and request_id that can be searched, filtered, and aggregated by log management platforms.
Why is JSON the standard format for structured logs?
JSON is natively supported by every major log aggregation platform (Datadog, Grafana Loki, Elasticsearch, AWS CloudWatch, Google Cloud Logging). It requires no custom parser configuration, supports nested data, and is human-readable when pretty-printed for debugging.
What fields should every JSON log entry contain?
At minimum: timestamp (ISO 8601 UTC), level (info/warn/error), message (human-readable description), and service (originating service name). For distributed systems, add trace_id, span_id, and request_id to enable cross-service correlation.
How do I correlate logs across microservices?
Propagate a trace_id and span_id through HTTP headers (W3C Trace Context or B3 format) and include them in every log entry. OpenTelemetry provides automatic instrumentation that injects these fields. Most observability platforms can then link logs, traces, and metrics together.
Should I use pino or winston for Node.js logging?
Pino is significantly faster (~5x) because it uses worker threads for serialization and avoids synchronous I/O. Winston is more flexible with transports but slower. For production APIs where throughput matters, pino is the recommended choice. Winston is fine for CLI tools or low-traffic applications.
How do I avoid logging sensitive data in JSON?
Use redaction: pino supports a redact option that masks fields by path (e.g., ["req.headers.authorization", "body.password"]). Never log PII (emails, SSNs), authentication tokens, or credit card numbers. Implement a sanitization middleware that strips sensitive fields before logging.
What is the difference between structured logging and JSON Lines?
JSON Lines (NDJSON) is a file format where each line is a valid JSON object, separated by newlines. Structured logging is the practice of emitting logs as structured data. In practice, structured logs are typically output in JSON Lines format — one JSON object per log entry, one per line — making them easy to stream, parse, and ingest.