Week 13: Observability & Production Readiness

Week 12 split your system into an API process and one or more worker processes — which means "what happened to this request" is no longer answerable by reading one process's console output top to bottom. This week gives you the tools to answer it anyway: structured JSON logs tagged with a request-correlation ID that follows a request across middleware, route handlers and queued jobs; liveness and readiness endpoints an orchestrator can poll to know whether your app is alive and able to serve traffic; and basic timing metrics. Week 14's deployment onto a container orchestrator assumes exactly these health-check endpoints already exist.

Module 10 of 22 Week 13 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Emit structured JSON logs tagged with a per-request correlation ID using AsyncLocalStorage
  • Implement separate liveness and readiness health-check endpoints and explain when each matters
  • Instrument request latency as a Prometheus-compatible metric

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.

terminal
npm install pino
npm install -D pino-pretty
src/lib/logger.ts
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"],
});
a production log line, unformatted
{"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.

Redact secrets at the logger, not per call site

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.

src/lib/request-context.ts
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";
}
src/middleware/request-id.ts
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:

src/lib/logger.ts (extended)
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.

Mount request-ID middleware before everything else

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:

src/routes/health.ts
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).

Keep liveness dependency-free, always

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.

terminal
npm install prom-client
src/lib/metrics.ts
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;
src/middleware/metrics.ts
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();
}
src/routes/metrics.ts
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.

Watch cardinality on labels

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

Hands-on

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:

  1. Add pino logging and replace every console.log/console.error in your route handlers with the structured logger.
  2. Add the request-ID middleware and AsyncLocalStorage context from Section 2, mounted first, and confirm the same requestId appears in every log line for a single request, including one logged from a helper function two calls deep.
  3. Add /healthz and /readyz exactly as in Section 3. Manually stop your Redis or Postgres container and confirm /readyz returns 503 while /healthz still returns 200.
  4. Add the metrics middleware and /metrics endpoint, hit a few routes a handful of times, and confirm http_request_duration_seconds shows up with per-route buckets when you curl /metrics.
  5. 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.
Hint

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?

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?

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?

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?

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.