When a request fails in production, the log is the only witness. console.log has no levels, no timestamps and no structure, so it cannot answer "which user hit which route with which request ID at 02:14?". After this lesson you will be able to add an access log with Morgan, produce structured JSON logs with Pino, attach a request ID to every line, and keep secrets out of the output.
Morgan writes one line per completed request, which is ideal for development and small apps.
npm install morganimport morgan from "morgan";
app.use(morgan(process.env.NODE_ENV === "production" ? "combined" : "dev"));| Format | Output style | Use |
| --- | --- | --- |
| dev | GET /todos 200 12.3 ms - 145, colour-coded status | Local development |
| tiny | Minimal method, URL, status, size, time | Noise-free terminals |
| short / common | Apache-style with remote address | Simple servers |
| combined | Apache combined: adds referrer and user agent | Production, log analysers |
Custom tokens and a skip function cover most needs:
morgan.token("id", (req) => req.id);
app.use(morgan(":id :method :url :status :response-time ms", {
skip: (req) => req.url === "/health",
}));To keep a file instead of the console, pass stream: fs.createWriteStream("access.log", { flags: "a" }). Morgan's limitation is that it only knows about HTTP; errors, jobs and startup messages need a general-purpose logger.
Pino is the fastest mainstream Node logger and emits one JSON object per line, which log platforms (Loki, Datadog, CloudWatch, ELK) index without parsing rules.
npm install pino pino-http
npm install --save-dev pino-pretty// src/lib/logger.js
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["req.headers.authorization", "req.headers.cookie", "*.password"],
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
});
logger.info({ port: 3000 }, "server started");
logger.error({ err }, "database connection failed");Levels are trace, debug, info, warn, error and fatal; setting level hides anything below it. The first argument is an object of searchable fields, the second a message. redact replaces matching paths with [Redacted] so a token never lands in a log file. In development pino-pretty renders readable lines; in production raw JSON goes to stdout and the platform collects it.
pino-http replaces Morgan when you use Pino: it logs each request on completion and gives every handler a req.log child logger that already carries the request ID.
import pinoHttp from "pino-http";
import { randomUUID } from "node:crypto";
import { logger } from "./lib/logger.js";
app.use(pinoHttp({
logger,
genReqId: (req, res) => {
const id = req.headers["x-request-id"] ?? randomUUID();
res.setHeader("X-Request-Id", id);
return id;
},
customLogLevel: (req, res, err) =>
err || res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info",
autoLogging: { ignore: (req) => req.url === "/health" },
}));
app.get("/orders/:id", async (req, res) => {
req.log.info({ orderId: req.params.id }, "fetching order");
res.json({ id: req.params.id });
});Reusing an incoming X-Request-Id lets a gateway or frontend correlate its own logs with yours; returning it in the response lets support staff ask users for it.
The error middleware is the right place to log failures, with the request context attached:
app.use((err, req, res, next) => {
const status = err.statusCode ?? 500;
const log = req.log ?? logger;
if (status >= 500) log.error({ err }, "unhandled error");
else log.warn({ err: { message: err.message } }, "request failed");
res.status(status).json({ error: status >= 500 ? "Internal Server Error" : err.message });
});Passing the error under the err key triggers Pino's error serialiser, which records type, message and stack as separate fields.
warn for client mistakes (4xx) and error only for things a developer must act on, so alerts stay meaningful.Which Morgan format is the usual choice for production access logs?
dev locally and combined in production.pino-http logs every request, generates or reuses a request ID and exposes req.log to handlers.err key so stack traces are captured as fields.error for actionable failures.Next lesson: Authentication with Sessions and JWT — identify users with server-side sessions or signed tokens and protect routes accordingly.