Node.js · lesson 7 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
Structured Logging with Pino
Replace console.log with Pino, attach a request id to every log line through pino-http's child logger, and read the one-line JSON it prints per request.
By SkillByExample · Updated
Pino prints one JSON object per log line instead of a formatted string, and pino-http attaches a per-request child logger that already carries that request’s id.
A Logger, and a Child Logger Per Request
// logger.js
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
});
// server.js
import express from "express";
import { randomUUID } from "node:crypto";
import pinoHttp from "pino-http";
import { logger } from "./logger.js";
const app = express();
app.use(
pinoHttp({
logger,
genReqId: (req) => req.headers["x-request-id"] ?? randomUUID().slice(0, 8),
})
);
app.use(express.json());
const users = [{ id: 1, name: "Alice" }];
app.get("/users/:id", (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
// req.log is a pino child logger with this request's id baked in already.
if (!user) {
req.log.warn({ userId: req.params.id }, "user lookup missed");
res.status(404).json({ error: "not found" });
return;
}
req.log.info({ userId: user.id }, "user lookup hit");
res.json(user);
});
app.listen(3000, () => logger.info("listening on http://localhost:3000"));
"pid":1 below is real, not a placeholder — it’s only true because the server is started as the container’s own entrypoint process, not backgrounded inside a shell:
$ docker run -d --name pino-demo -v "$(pwd)":/root/node-api -w /root/node-api node:26 node server.js
d5595da6793af74c5a697a6519e349db2a430d41a5be3a3155d85606a616d9d0
$ docker exec pino-demo curl -s -H 'x-request-id: req-42' http://localhost:3000/users/1
{"id":1,"name":"Alice"}
$ docker exec pino-demo curl -s http://localhost:3000/users/99
{"error":"not found"}
$ docker logs pino-demo
{"level":30,"time":1793212492427,"pid":1,"hostname":"d5595da6793a","msg":"listening on http://localhost:3000"}
{"level":30,"time":1793212495739,"pid":1,"hostname":"d5595da6793a","req":{"id":"req-42","method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*","x-request-id":"req-42"},"remoteAddress":"::1","remotePort":50166},"userId":1,"msg":"user lookup hit"}
{"level":30,"time":1793212495744,"pid":1,"hostname":"d5595da6793a","req":{"id":"req-42","method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*","x-request-id":"req-42"},"remoteAddress":"::1","remotePort":50166},"res":{"statusCode":200,"headers":{"x-powered-by":"Express","content-type":"application/json; charset=utf-8","content-length":"23","etag":"W/\"17-DSk1+U+0tpNEe1rQBEs8l28OulM\""}},"responseTime":6,"msg":"request completed"}
{"level":40,"time":1793212499669,"pid":1,"hostname":"d5595da6793a","req":{"id":"42f0bf02","method":"GET","url":"/users/99","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":50174},"userId":"99","msg":"user lookup missed"}
{"level":30,"time":1793212499669,"pid":1,"hostname":"d5595da6793a","req":{"id":"42f0bf02","method":"GET","url":"/users/99","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":50174},"res":{"statusCode":404,"headers":{"x-powered-by":"Express","content-type":"application/json; charset=utf-8","content-length":"21","etag":"W/\"15-3jlv4LtvSUoQruAmr3ef7Px06u0\""}},"responseTime":0,"msg":"request completed"}
Every line is a single JSON object (time, pid, and hostname will differ on your machine). The req.id on the first two lines is "req-42" — the value we sent in x-request-id — while the last two show a generated "42f0bf02", because the second request sent no header at all. req.log.warn on a miss produced "level":40; req.log.info on a hit produced "level":30 — both lines still carry the same request’s req.id as the "request completed" line pino-http logs automatically right after, which is what makes it possible to grep one request’s whole story out of a log stream by its id alone.
Practice
1. Add a req.log.error call in a new route that always fails, and read the err object pino attaches.
Solution
// server.js (added route)
app.get("/boom", (req, res) => {
try {
throw new Error("kaboom");
} catch (err) {
req.log.error({ err }, "handled boom");
res.status(500).json({ error: "internal error" });
}
});
$ docker restart pino-demo
pino-demo
$ docker exec pino-demo curl -s http://localhost:3000/boom
{"error":"internal error"}
$ docker logs --tail 2 pino-demo
{"level":50,"time":1793212541887,"pid":1,"hostname":"d5595da6793a","req":{"id":"ece9594c","method":"GET","url":"/boom","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":39412},"err":{"type":"Error","message":"kaboom","stack":"Error: kaboom\n at file:///root/node-api/server.js:33:11\n at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)\n at next (/root/node-api/node_modules/router/lib/route.js:157:13)\n at Route.dispatch (/root/node-api/node_modules/router/lib/route.js:117:3)\n at handle (/root/node-api/node_modules/router/index.js:435:11)\n at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)\n at /root/node-api/node_modules/router/index.js:295:15\n at processParams (/root/node-api/node_modules/router/index.js:582:12)\n at next (/root/node-api/node_modules/router/index.js:291:5)\n at read (/root/node-api/node_modules/body-parser/lib/read.js:59:5)"},"msg":"handled boom"}
{"level":30,"time":1793212541891,"pid":1,"hostname":"d5595da6793a","req":{"id":"ece9594c","method":"GET","url":"/boom","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":39412},"res":{"statusCode":500,"headers":{"x-powered-by":"Express","content-type":"application/json; charset=utf-8","content-length":"26","etag":"W/\"1a-nO1SMtFqjAylRZvcD/JJ2LGv/qs\""}},"err":{"type":"Error","message":"failed with status code 500","stack":"Error: failed with status code 500\n at onResFinished (/root/node-api/node_modules/pino-http/logger.js:115:39)\n at ServerResponse.onResponseComplete (/root/node-api/node_modules/pino-http/logger.js:178:14)\n at ServerResponse.emit (node:events:526:24)\n at onFinish (node:_http_outgoing:1275:10)\n at callback (node:internal/streams/writable:805:21)\n at afterWrite (node:internal/streams/writable:749:5)\n at afterWriteTick (node:internal/streams/writable:735:10)\n at process.processTicksAndRejections (node:internal/process/task_queues:89:21)"},"responseTime":4,"msg":"request errored"}
Adding the route means the running server needs its file re-read, hence docker restart (the mounted file already has the new route; restart just re-execs node server.js), and --tail 2 here only grabs the two new lines instead of this container’s whole history. Passing { err } as the first argument gives Pino’s standard serializers a chance to expand the Error into type, message, and stack fields instead of logging an opaque [object Object] or losing the stack entirely. There’s a second line here too: pino-http’s own completion log isn’t always "msg":"request completed" — once the status code is 500 or higher it logs "msg":"request errored" instead, with its own synthetic err describing the failed response, not the original kaboom.
2. Set LOG_LEVEL=warn and confirm the “user lookup hit” info-level line disappears while the warn-level miss still logs.
Solution
$ docker run -d --name pino-warn-demo -e LOG_LEVEL=warn -v "$(pwd)":/root/node-api -w /root/node-api node:26 node server.js
602c8855cc1a53a4f1ef55a50c631214b12126f883e982f38bdacbb48b6c1d4b
$ docker exec pino-warn-demo curl -s -o /dev/null http://localhost:3000/users/1
$ docker exec pino-warn-demo curl -s -o /dev/null http://localhost:3000/users/99
$ docker logs pino-warn-demo
{"level":40,"time":1793212280848,"pid":1,"hostname":"602c8855cc1a","req":{"id":"e5ecd3bb","method":"GET","url":"/users/99","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":52564},"userId":"99","msg":"user lookup missed"}
No "msg":"user lookup hit" line, no "msg":"listening on..." line (that’s info, level 30, now below the warn threshold of 40), and no "request completed" lines either — pino-http’s own completion log defaults to info. Only the one warn-level line survives the filter.
3. Add a child logger bound to a fixed { service: "users-api" } field at startup, and confirm every log line includes it.
Solution
// logger.js (bound child logger)
import pino from "pino";
const baseLogger = pino({ level: process.env.LOG_LEVEL ?? "info" });
export const logger = baseLogger.child({ service: "users-api" });
$ docker run -d --name pino-service-demo -v "$(pwd)":/root/node-api -w /root/node-api node:26 node server.js
054c41483530ada6927db010727d49b492929fe07e12ab0930b9b39545629523
$ docker exec pino-service-demo curl -s -o /dev/null http://localhost:3000/users/1
$ docker logs pino-service-demo
{"level":30,"time":1793212312420,"pid":1,"hostname":"054c41483530","service":"users-api","msg":"listening on http://localhost:3000"}
{"level":30,"time":1793212315253,"pid":1,"hostname":"054c41483530","service":"users-api","req":{"id":"d9c5fe05","method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":41126},"userId":1,"msg":"user lookup hit"}
{"level":30,"time":1793212315257,"pid":1,"hostname":"054c41483530","service":"users-api","req":{"id":"d9c5fe05","method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":41126},"res":{"statusCode":200,"headers":{"x-powered-by":"Express","content-type":"application/json; charset=utf-8","content-length":"23","etag":"W/\"17-DSk1+U+0tpNEe1rQBEs8l28OulM\""}},"responseTime":4,"msg":"request completed"}
"service":"users-api" appears on every line, including the ones produced through pinoHttp’s own req.log and its own "request completed" line, because pino-http was configured with this already-bound logger and every child logger it creates inherits the bindings of its parent.
4. Compare a request through curl with -H 'x-request-id;' (curl’s syntax for sending a header with a genuinely empty value) to one with no header at all.
Solution
$ docker run -d --name pino-demo2 -v "$(pwd)":/root/node-api -w /root/node-api node:26 node server.js
f89e0152516a4293f1a47e6b27aa74457389f358eccfb9fdc9bf31ba60d72522
$ docker exec pino-demo2 curl -s -H 'x-request-id;' -o /dev/null http://localhost:3000/users/1
$ docker exec pino-demo2 curl -s -o /dev/null http://localhost:3000/users/1
$ docker logs pino-demo2
{"level":30,"time":1793281223884,"pid":1,"hostname":"f89e0152516a","msg":"listening on http://localhost:3000"}
{"level":30,"time":1793281225709,"pid":1,"hostname":"f89e0152516a","req":{"method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*","x-request-id":""},"remoteAddress":"::1","remotePort":56552},"userId":1,"msg":"user lookup hit"}
{"level":30,"time":1793281225713,"pid":1,"hostname":"f89e0152516a","req":{"method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*","x-request-id":""},"remoteAddress":"::1","remotePort":56552},"res":{"statusCode":200,"headers":{"x-powered-by":"Express","content-type":"application/json; charset=utf-8","content-length":"23","etag":"W/\"17-DSk1+U+0tpNEe1rQBEs8l28OulM\""}},"responseTime":5,"msg":"request completed"}
{"level":30,"time":1793281229897,"pid":1,"hostname":"f89e0152516a","req":{"id":"f548d637","method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":56564},"userId":1,"msg":"user lookup hit"}
{"level":30,"time":1793281229897,"pid":1,"hostname":"f89e0152516a","req":{"id":"f548d637","method":"GET","url":"/users/1","query":{},"params":{},"headers":{"host":"localhost:3000","user-agent":"curl/8.14.1","accept":"*/*"},"remoteAddress":"::1","remotePort":56564},"res":{"statusCode":200,"headers":{"x-powered-by":"Express","content-type":"application/json; charset=utf-8","content-length":"23","etag":"W/\"17-DSk1+U+0tpNEe1rQBEs8l28OulM\""}},"responseTime":0,"msg":"request completed"}
This is a fresh container, so docker logs starts from its own first line, "msg":"listening on http://localhost:3000", not just the two requests below it. Two things are easy to get wrong here. First, curl -H 'x-request-id: ' (colon, then nothing) doesn’t send an empty header at all — curl treats that as “remove this header”; curl -H 'x-request-id;' (semicolon) is what actually sends it with an empty value, visible in the request’s own headers.x-request-id: "". Second, req.headers["x-request-id"] ?? randomUUID()... sees "", which isn’t undefined or null, so ?? doesn’t fall back — genReqId really does return "". But the first pair of log lines has no "id" key in req at all, not "id":"": pino-http’s own request serializer only includes id when it’s truthy, so an empty string id disappears from the log entirely, silently, rather than showing up as an empty value. Only the second, header-less request gets a visible generated id ("f548d637") on both its "user lookup hit" and "request completed" lines.