When production misbehaves, logs, metrics and traces are your only window into what happened.
Observability means being able to answer "what is happening and why?" from the outside. For an Express service that's three signals.
Structured logs. Log JSON, not prose, so your platform can filter by field. pino with pino-http is fast and idiomatic; morgan is a simpler access logger. Each request should produce one access-log line with method, route, status, duration and a request ID. Accept an incoming X-Request-Id (or generate one with crypto.randomUUID()), return it in the response, and include it in every log line so you can follow a request across services.
Rules of thumb:
- Log errors once, in the error handler, with the stack and request context — not at every layer that rethrows.
- Never log secrets, passwords, tokens or full request bodies; use redaction options.
- Use levels (
debug,info,warn,error) and set the level from the environment. - Write to stdout and let the platform ship logs; don't manage log files in Node.
Metrics. Request rate, error rate and latency (p50/p95/p99) per route, plus event-loop delay and memory. Expose them for Prometheus or push via OpenTelemetry. Use the route pattern (req.route?.path), not the raw URL, as a label, or every user id becomes a new time series.
Traces. OpenTelemetry's Node SDK auto-instruments Express, http, fetch and popular database drivers, producing spans that show where a slow request spent its time across services.
Health checks. A cheap /healthz (liveness: the process is up) and a /readyz (readiness: dependencies reachable, not shutting down). Keep them out of access logs and rate limits.
Wrap context in AsyncLocalStorage so deep code can log with the request ID without passing it around.
Example
import express from "express";
import { pinoHttp } from "pino-http";
import { randomUUID } from "node:crypto";
const app = express();
app.use(pinoHttp({
level: process.env.LOG_LEVEL ?? "info",
genReqId(req, res) {
const id = req.headers["x-request-id"] ?? randomUUID();
res.setHeader("x-request-id", id);
return id;
},
redact: ["req.headers.authorization", "req.headers.cookie"],
autoLogging: { ignore: (req) => req.url === "/healthz" },
}));
app.get("/healthz", (req, res) => res.send("ok"));
app.get("/orders/:id", (req, res) => {
req.log.info({ orderId: req.params.id }, "loading order"); // carries reqId automatically
res.json({ id: req.params.id });
});
app.get("/fail", async () => { throw new Error("payment provider timeout"); });
app.use((err, req, res, next) => {
req.log.error({ err, route: req.route?.path }, "request failed"); // logged once, with context
res.status(500).json({ error: "Internal Server Error", requestId: req.id });
});
app.listen(3000);Remember
- One structured access log per request, tagged with a request ID
- Log errors once with context; redact secrets
- Metrics by route pattern; traces via OpenTelemetry
- Separate liveness and readiness endpoints
Watch out for
- Labelling metrics with the raw URL, creating unbounded cardinality.