Logging with Morgan and Pino

Intermediate
12 min

Logging with Morgan and Pino

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: quick access logs

Morgan writes one line per completed request, which is ideal for development and small apps.

bash
npm install morgan
javascript
import 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:

javascript
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: structured JSON logs

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.

bash
npm install pino pino-http npm install --save-dev pino-pretty
javascript
// 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.

Request logging with pino-http

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.

javascript
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.

Logging errors in one place

The error middleware is the right place to log failures, with the request context attached:

javascript
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.

Tips

  • Log to stdout in containers and let the platform ship the stream; writing files inside a container loses them on redeploy.
  • Never log request bodies wholesale; they contain passwords, cards and personal data. Log IDs and counts instead.
  • Use warn for client mistakes (4xx) and error only for things a developer must act on, so alerts stay meaningful.
Quick Quiz
Question 1 of 3

Which Morgan format is the usual choice for production access logs?

Key Takeaways

  • Morgan provides one-line access logs with ready-made formats; use dev locally and combined in production.
  • Pino produces fast, structured JSON logs with levels, redaction and a pretty transport for development.
  • pino-http logs every request, generates or reuses a request ID and exposes req.log to handlers.
  • Log errors in the error middleware with the err key so stack traces are captured as fields.
  • Log to stdout, never log secrets or full bodies, and reserve error for actionable failures.

Next lesson: Authentication with Sessions and JWT — identify users with server-side sessions or signed tokens and protect routes accordingly.

Logging with Morgan and Pino - Express.js | CodeYourCraft | CodeYourCraft