Logging is the most used debugging tool in production and the most neglected during development. When something breaks at 2 AM, your logs either tell you exactly what happened or they leave you guessing. The difference is almost entirely structure: logs as searchable, parseable JSON with consistent context fields versus free-form strings that require human pattern-matching. Getting logging right is cheap and pays continuously.
What changed in 2026
- OpenTelemetry's Logs signal graduated to stable, meaning logs, traces, and metrics share the same context propagation. A
trace_id that started in a trace automatically appears in logs without manual plumbing.
- Pino v9 (Node.js) and structlog v24 (Python) became the clear defaults for structured logging — both emit JSON, are fast, and integrate with OTEL.
- Log aggregation platforms (Datadog, Grafana Loki, AWS CloudWatch) all expect JSON natively and surface log-trace correlation automatically.
console.log in Node.js is now explicitly discouraged in production docs — it is synchronous, blocks the event loop on high volume, and produces plain strings.
Structured vs unstructured logging
// Unstructured — hard to search, parse, or alert on
console.log("User 1234 placed order 5678 for $99.00");
// Structured — every field is queryable
logger.info({
userId: "1234",
orderId: "5678",
amount: 99.00,
event: "order.placed",
});
// Output: {"level":"info","userId":"1234","orderId":"5678","amount":99,"event":"order.placed","time":"2026-06-03T..."}
In a structured log store, you can query orderId = "5678" or amount > 100 across millions of lines in milliseconds.
Log levels — what each means
| Level |
When to use |
Production default |
trace |
Step-by-step execution detail |
Off |
debug |
Useful dev context (queries, cache hits) |
Off |
info |
Normal business events (request handled, order placed) |
On |
warn |
Unexpected but recoverable (retry, degraded mode) |
On |
error |
Operation failed; action may be needed |
On + alert |
fatal |
Process must exit |
On + page |
The rule: info should tell the story of what the system did; error should wake someone up.
Setting up Pino (Node.js)
// logger.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
// In production, emit JSON; in dev, use pino-pretty for human output
transport: process.env.NODE_ENV === "development"
? { target: "pino-pretty" }
: undefined,
});
// Usage
logger.info({ userId, orderId }, "order placed");
logger.error({ err, userId }, "payment failed");
Setting up structlog (Python)
# logging_config.py
import structlog
import logging
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
log = structlog.get_logger()
log.info("order.placed", user_id=user_id, order_id=order_id, amount=amount)
Request context propagation
Every log line in a request should carry the same trace/request ID without passing it through every function:
// Express middleware — attach request context
import { AsyncLocalStorage } from "async_hooks";
const requestContext = new AsyncLocalStorage<{ requestId: string; userId?: string }>();
app.use((req, res, next) => {
requestContext.run({ requestId: req.headers["x-request-id"] as string }, next);
});
// logger reads context automatically
const logger = pino({
mixin() {
return requestContext.getStore() ?? {};
},
});
Now every logger.info(...) call in that request automatically includes requestId without threading it manually.
How to pick
- Node.js service — Pino. Fast async logging, JSON-native, OTEL support.
- Python service — structlog. Context vars, processor pipeline, JSON output.
- Go service — slog (stdlib since 1.21). JSON handler out of the box.
- Log aggregation — Grafana Loki (self-hosted) or Datadog Logs (managed). Both parse JSON natively.
Common mistakes
Logging inside loops or hot paths. Logging is I/O. Even async loggers have overhead. Use debug level and disable it in production; never log per-row in a bulk query.
Logging PII without scrubbing. User emails, phone numbers, and payment info in logs are a compliance liability. Redact before logging, or use a log pipeline that scrubs fields.
Giant log objects. logger.info({ req, res, user }) dumps thousands of lines per request. Log the IDs and specific fields, not entire objects.
Missing error objects. logger.error("payment failed") without { err } loses the stack trace. Always pass the error as a field.
What to skip
console.log in production — no levels, synchronous, unstructured.
- Reinventing log transport — use a dedicated log aggregation service rather than writing your own log shipper.
- Logging every function entry/exit by default — it drowns signal in noise. Log at the boundary (HTTP handler, queue consumer) and within error paths.
FAQ
How much does logging cost in production?
JSON logging with Pino or structlog adds ~1–2 ms per request at moderate volume. The bigger cost is storage in your log aggregator — filter aggressively with log levels.
What is the difference between logging and tracing?
Logs are discrete events; traces track the flow of a request across services with timing. OpenTelemetry 2026 unifies both under a shared context so log lines link to their trace spans.
How long should I retain logs?
90 days for info/debug is common; 1 year for error/warn for compliance. Retention policies depend on your industry and data residency requirements.
How do I avoid logging sensitive data?
Define a schema for what is allowed. Use a log processor (structlog processor or Pino redact option) to scrub known sensitive field names before emission.
Where to go next
See Error handling explained in 2026, How to monitor a service in 2026, and Environment variables explained in 2026.