Structured Logging

Emit JSON Logs with Pino and Attach a Request ID to Every Line

Structured Logging

Replace ad-hoc console.log calls with koa-pino-logger to emit structured JSON logs enriched with a request ID, HTTP method, path, status, and response time.

4 min read Level 2/5 #koa#production#logging
What you'll learn
  • Add koa-pino-logger as request-logging middleware
  • Attach a unique request ID to every log line via ctx.state
  • Configure log levels for development versus production

console.log is fine for development but becomes a liability in production: messages are unstructured, hard to query, and carry no request context. Pino is the fastest production logger for Node.js and emits newline-delimited JSON that log aggregators (Loki, Datadog, CloudWatch) can index directly.

Installation

npm i koa-pino-logger pino pino-pretty

Wiring Up the Logger

Register koa-pino-logger before your router so every request is timed and logged on the way out.

import Koa from "koa";
import pinoLogger from "koa-pino-logger";

const app = new Koa();

app.use(
  pinoLogger({
    level: process.env.LOG_LEVEL ?? "info",
    transport:
      process.env.NODE_ENV !== "production"
        ? { target: "pino-pretty" }
        : undefined,
  })
);

// … router, other middleware …
app.listen(3000);

Each request now produces one JSON line after the response is sent:

{"level":30,"time":1716038400000,"req":{"method":"GET","url":"/ping"},"res":{"statusCode":200},"responseTime":4,"msg":"request completed"}

Adding a Request ID

A request ID lets you correlate every log line for a single request — invaluable when debugging concurrent traffic.

import { randomUUID } from "node:crypto";

app.use(async (ctx, next) => {
  ctx.state.requestId =
    ctx.get("X-Request-Id") || randomUUID();
  ctx.set("X-Request-Id", ctx.state.requestId);
  await next();
});

app.use(
  pinoLogger({
    genReqId: (req, res) => res.getHeader("x-request-id"),
  })
);

Logging Inside Route Handlers

koa-pino-logger attaches a child logger to ctx.log. Use it instead of console.log so child log lines inherit the request ID automatically.

router.get("/orders/:id", async (ctx) => {
  ctx.log.info({ orderId: ctx.params.id }, "fetching order");
  const order = await db.findOrder(ctx.params.id);
  if (!order) {
    ctx.log.warn({ orderId: ctx.params.id }, "order not found");
    ctx.throw(404, "Order not found");
  }
  ctx.body = order;
});

Log Levels

LevelNumericWhen to use
trace10Verbose debugging — disable in production
debug20Development diagnostics
info30Normal request flow (default)
warn40Recoverable problems
error50Unhandled errors, caught exceptions
fatal60Process must exit

Set LOG_LEVEL=debug in development and LOG_LEVEL=warn or LOG_LEVEL=info in production to control verbosity without a code change.

Up Next

Expose health and readiness endpoints and track real-time metrics with prom-client.

Monitoring and Metrics →