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:
| Approach | Query | Time to Answer |
|---|---|---|
| Unstructured | grep "error" app.log | grep "payment" | grep -c "500" | Minutes to hours |
| Structured JSON | level:"error" AND service:"payment" AND status:500 | stats count by error_code | Seconds |
Unstructured vs Structured
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 123453[2026-04-15 14:30:02] ERROR PaymentService - Payment retry failed for user 12345: card declined1{"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
| Field | Type | Purpose | Example |
|---|---|---|---|
| timestamp | string (ISO 8601) | When the event occurred (UTC) | 2026-04-15T14:30:01.234Z |
| level | string | Severity: debug/info/warn/error/fatal | error |
| message | string | Human-readable event description | Payment failed |
| service | string | Originating service identifier | payment-service |
| trace_id | string | Distributed trace ID (W3C/OpenTelemetry) | 4bf92f3577b34da6a3ce929d |
| span_id | string | Current span within the trace | 00f067aa0ba902b7 |
| request_id | string | Unique request identifier | req_01HXYZ9ABC |
| environment | string | Deployment environment | production |
| version | string | Application version | 2.4.1 |
Field Naming Convention
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:
| Level | When to Use | Production Volume | Alert? |
|---|---|---|---|
| fatal | Process cannot continue, will exit | Extremely rare | Immediate page |
| error | Operation failed, needs investigation | Low | Alert within minutes |
| warn | Unexpected but handled (retries, fallbacks) | Moderate | Dashboard monitoring |
| info | Significant business events (user signup, payment) | High | No |
| debug | Diagnostic detail (query params, cache hits) | Very high | Never (disabled in prod) |
| trace | Fine-grained execution flow | Extreme | Never (dev only) |
Warning
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:
1import pino from 'pino';23const logger = pino({4 level: process.env.LOG_LEVEL || 'info',5 timestamp: pino.stdTimeFunctions.isoTime,67 // Redact sensitive fields8 redact: {9 paths: [10 'req.headers.authorization',11 'req.headers.cookie',12 'body.password',13 'body.creditCard',14 ],15 censor: '[REDACTED]',16 },1718 // Base fields included in every log entry19 base: {20 service: 'payment-service',21 version: process.env.APP_VERSION || '0.0.0',22 environment: process.env.NODE_ENV || 'development',23 },2425 // Format for development readability26 transport: process.env.NODE_ENV !== 'production'27 ? { target: 'pino-pretty', options: { colorize: true } }28 : undefined,29});3031export default logger;1import logger from './logger.js';23app.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 });89 requestLogger.info({ amount: req.body.amount }, 'Payment initiated');1011 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
1import pinoHttp from 'pino-http';2import logger from './logger.js';34app.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}));2223// 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
1import structlog2import logging34structlog.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)1718log = structlog.get_logger(service="user-service", version="1.2.0")1from fastapi import FastAPI, Request2import structlog34app = FastAPI()5log = structlog.get_logger()67@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 response1819@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, path23 return {"status": "created"}Correlating Logs with OpenTelemetry
1import { trace, context } from '@opentelemetry/api';2import pino from 'pino';34const 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});1617// Now every log entry automatically includes trace_id and span_id18// from the active OpenTelemetry context19logger.info('Processing payment');20// Output: {"level":"info","trace_id":"4bf92f35...","span_id":"00f067aa...",21// "message":"Processing payment"}Note
Log Pipeline Architecture
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).
| Component | Options | Strengths |
|---|---|---|
| Log Collector | Vector, Fluentd, Fluent Bit, Logstash | Vector: Rust-based, low memory; Fluentd: mature ecosystem |
| Log Storage | Elasticsearch, Grafana Loki, ClickHouse | Loki: label-based (cheap); ES: full-text search (powerful) |
| Dashboard | Kibana, Grafana, Datadog | Grafana: unified metrics+logs+traces; Datadog: managed SaaS |
| Alerting | Grafana Alerting, PagerDuty, OpsGenie | Define 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 Log | Why | Alternative |
|---|---|---|
| Passwords or hashes | Credential exposure | Log authentication result only (success/fail) |
| Authorization tokens | Session hijacking | Log token prefix or hash: tok_abc... |
| Credit card numbers | PCI-DSS violation | Log last 4 digits only: ****1234 |
| Email addresses, SSNs | Privacy regulation (GDPR) | Log anonymized user_id instead |
| Full request bodies | May contain any of the above | Log specific safe fields explicitly |
| API keys or secrets | Full system compromise | Log key prefix or name, never value |
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 fields10 ],11 censor: '[REDACTED]',12 },13});1415// Input: { body: { password: "secret123", username: "alice" } }16// Output: { body: { password: "[REDACTED]", username: "alice" } }Analyzing JSON Logs with jq
1# Find all errors in the last hour2cat app.log | jq 'select(.level == "error")'34# Slow requests (> 1 second)5cat app.log | jq 'select(.duration_ms > 1000) | {path: .url, duration_ms, timestamp}'67# Error rate by service8cat app.log | jq -s '9 group_by(.service) | map({10 service: .[0].service,11 total: length,12 errors: [.[] | select(.level == "error")] | length13 })'1415# Trace a single request across services16cat app.log | jq 'select(.request_id == "req_01HXYZ9ABC")'1718# Top 10 slowest endpoints19cat 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_idandrequest_idin 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.login production — it blocks the Node.js event loop - ✗Avoid logging entire request/response bodies — they may contain sensitive data and bloat storage