1. Structured JSON Logging
console.log is fine for local development and a genuine liability in
production: it produces free-text lines that a log aggregator (Datadog, CloudWatch,
Loki) can't reliably filter or query on fields like status code or user ID.
Structured logging emits each log line as a JSON object instead, so
every field is queryable. pino is the standard choice for Node — it's
fast and JSON-first by default.
npm install pino
npm install -D pino-pretty
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
// In dev, pretty-print for humans reading a terminal. In production,
// emit raw JSON -- that's what the log aggregator actually wants.
transport: isDev ? { target: "pino-pretty" } : undefined,
redact: ["req.headers.authorization", "req.headers.cookie", "*.password"],
});
{"level":30,"time":1735682400123,"msg":"user created","userId":"usr_9f2","route":"/signup"}
Every field in that object is now something a log platform can filter, group, and
alert on — "show me every log line where route is /signup
and level is 50 (error)" is a query, not a manual grep through
free-text output.
The redact option strips sensitive fields (authorization headers, cookies, password fields) from every log line automatically, regardless of which route or middleware logged it. Relying on every individual logger.info(...) call to remember to omit secrets is exactly the kind of thing that eventually gets missed once.
2. Request Correlation IDs with AsyncLocalStorage
A single user action can touch multiple log lines across middleware, a route handler, and — as of Week 12 — a queued job processed by a completely different worker process. A correlation ID (or request ID) is a unique value generated once per request and attached to every log line produced while handling it, so you can filter your log aggregator to that one ID and see the entire story.
Threading that ID through every function call as an extra parameter is invasive and
easy to forget. Node's built-in AsyncLocalStorage solves this properly:
it's a store that's automatically available to any code running "underneath" an async
call, without passing it explicitly.
import { AsyncLocalStorage } from "node:async_hooks";
type RequestContext = { requestId: string };
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getRequestId(): string {
return requestContext.getStore()?.requestId ?? "no-request-context";
}
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { requestContext } from "../lib/request-context.js";
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
// Reuse an upstream ID (e.g. from a load balancer or gateway) if present,
// so a request's ID stays the same across every hop, not just inside this service.
const requestId = (req.headers["x-request-id"] as string | undefined) ?? randomUUID();
res.setHeader("x-request-id", requestId);
requestContext.run({ requestId }, () => {
next();
});
}
Mount it as the very first middleware, before anything else runs, then have your logger automatically pull the ID out of the store on every call:
import pino from "pino";
import { getRequestId } from "./request-context.js";
const base = pino({ level: process.env.LOG_LEVEL ?? "info" });
export const logger = {
info: (obj: Record<string, unknown>, msg: string) =>
base.info({ ...obj, requestId: getRequestId() }, msg),
error: (obj: Record<string, unknown>, msg: string) =>
base.error({ ...obj, requestId: getRequestId() }, msg),
};
Now any code deep inside a route handler, a service function, or an error handler can
call logger.info(...) and the correlation ID appears automatically —
nothing had to pass it down manually. Extend the same job payload in Week 12's queue
to carry the originating requestId, and the worker's logs can be
correlated back to the request that enqueued them too.
Register it as the first item in your middleware chain, ahead of body parsing, the timing middleware from Week 11, and your routers. Any middleware or route that runs before it won't have a request ID available in its own log lines.
3. Liveness & Readiness Health Checks
Container orchestrators like Kubernetes (and the platform you'll deploy to in Week 14) need a way to ask your app two genuinely different questions, and conflating them causes real production incidents:
- Liveness — "is this process still running and not deadlocked?" A failing liveness check tells the orchestrator to kill and restart the container.
- Readiness — "is this instance currently able to serve real traffic?" A failing readiness check tells the orchestrator to stop routing new requests here, without restarting anything.
A liveness check should be cheap and dependency-free — it answers "is the Node process itself responsive," nothing more:
import { Router } from "express";
import { prisma } from "../lib/prisma.js";
import { redis } from "../lib/redis.js";
export const healthRouter = Router();
// Liveness: just confirm the process can respond at all. No dependency checks --
// if the database is down but the process is fine, restarting the container
// would NOT fix the database and would just add a pointless restart loop.
healthRouter.get("/healthz", (_req, res) => {
res.status(200).json({ status: "ok" });
});
// Readiness: confirm this instance can actually serve a real request right now.
healthRouter.get("/readyz", async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
await redis.ping();
res.status(200).json({ status: "ready" });
} catch (err) {
res.status(503).json({ status: "not ready", error: (err as Error).message });
}
});
The difference matters most exactly when a dependency is briefly unavailable: if a
database connection blip made /healthz fail too, the orchestrator would
restart every instance of your app in response to a problem a restart can't fix, while
genuinely making things worse (a burst of container restarts under a database outage,
instead of instances simply pausing traffic until the database recovers).
It's tempting to add a database ping to /healthz "just to be thorough." Resist it — that single check is the one your orchestrator uses to decide whether to kill your container, and you never want an external dependency's downtime to trigger unnecessary restarts of a perfectly healthy process.
4. Metrics & Timing Instrumentation
Logs answer "what happened on this one request." Metrics answer "how is the system
behaving in aggregate, right now" — request rate, error rate, and latency
distribution. prom-client is the standard Node library for exposing
metrics in the Prometheus format most monitoring stacks (Prometheus, Grafana, Datadog)
can scrape directly.
npm install prom-client
import client from "prom-client";
client.collectDefaultMetrics(); // process CPU, memory, event loop lag, etc.
export const httpRequestDuration = new client.Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
export const registry = client.register;
import type { Request, Response, NextFunction } from "express";
import { httpRequestDuration } from "../lib/metrics.js";
export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void {
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
end({
method: req.method,
// req.route.path is the pattern ("/products/:id"), not the literal URL --
// using the literal URL would create a separate metric series per product ID.
route: req.route?.path ?? "unmatched",
status_code: res.statusCode,
});
});
next();
}
import { Router } from "express";
import { registry } from "../lib/metrics.js";
export const metricsRouter = Router();
metricsRouter.get("/metrics", async (_req, res) => {
res.set("Content-Type", registry.contentType);
res.send(await registry.metrics());
});
A histogram (rather than a running average) is the right shape for latency: it buckets observations so you can compute percentiles like p50, p95 and p99 after the fact. An average of 80ms can hide a p99 of 4 seconds affecting one in a hundred users; a histogram makes that tail visible instead of averaging it away.
Every unique combination of label values (method, route, status_code) creates a new time series in Prometheus. Using the route pattern instead of the raw URL is what keeps that number bounded — a raw URL label would create one series per unique product ID ever requested, which can silently overwhelm a metrics backend.
5. Hands-on Exercise
Make your API observable end to end
Wire up structured logging, correlation IDs, both health-check endpoints, and a latency metric on a real app, then prove each one works.
Requirements:
- Add
pinologging and replace everyconsole.log/console.errorin your route handlers with the structured logger. - Add the request-ID middleware and
AsyncLocalStoragecontext from Section 2, mounted first, and confirm the samerequestIdappears in every log line for a single request, including one logged from a helper function two calls deep. - Add
/healthzand/readyzexactly as in Section 3. Manually stop your Redis or Postgres container and confirm/readyzreturns 503 while/healthzstill returns 200. - Add the metrics middleware and
/metricsendpoint, hit a few routes a handful of times, and confirmhttp_request_duration_secondsshows up with per-route buckets when you curl/metrics. - Deliberately trigger an error in one route and confirm the resulting error log line includes the
requestId, so you could trace it back to the exact request that caused it.
Use docker stop (or pause the container) on Redis or Postgres rather than uninstalling it, so you can bring it right back with docker start once you've confirmed /readyz reacted correctly — this makes the exercise fast to repeat.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What goes wrong if your liveness check also verifies the database connection, and the database has a brief outage?
What goes wrong if your liveness check also verifies the database connection, and the database has a brief outage?
The orchestrator interprets a failing liveness check as "this process is broken, kill and restart it" -- but restarting a perfectly healthy Node process does nothing to fix a database that's down, so every instance of your app gets needlessly killed and restarted, possibly in a repeating loop, while the real problem (the database) is completely unaffected. Readiness exists precisely to express "temporarily can't serve traffic" without triggering a pointless restart.
Q2
Why does AsyncLocalStorage let a deeply nested helper function access the request ID without it being passed as a parameter?
Why does AsyncLocalStorage let a deeply nested helper function access the request ID without it being passed as a parameter?
AsyncLocalStorage.run(store, callback) makes that store implicitly available to the callback and to every async operation that gets scheduled underneath it -- Node tracks and propagates that context automatically across await boundaries and callbacks, similar to how a thread-local variable works in a threaded language. Any code executing as part of that same asynchronous chain can call getStore() and retrieve it, with no explicit parameter threading required.
Q3
Why use the route pattern (/products/:id) rather than the literal request URL as a metric label?
Why use the route pattern (/products/:id) rather than the literal request URL as a metric label?
Prometheus creates a separate time series for every unique combination of label values it sees. The literal URL contains a different product ID per request, so using it as a label would create one time series per product ever requested -- a number that grows without bound and can overwhelm the metrics backend. The route pattern is the same for every request to that endpoint regardless of which ID was in the URL, keeping the label's cardinality small and fixed.
Q4
Why can an average latency of 80ms hide a serious performance problem that a histogram-based p99 would reveal?
Why can an average latency of 80ms hide a serious performance problem that a histogram-based p99 would reveal?
An average blends every request into a single number, so a small fraction of very slow requests (say, 1% taking 4 seconds) can be completely diluted by the other 99% being fast, leaving the average looking healthy. A histogram preserves the distribution of individual observations, so you can compute a percentile like p99 that specifically surfaces "how slow is the request at the unlucky end of the distribution" -- which is exactly the experience a meaningful slice of real users are actually having.