Week 12: Observability & Production Readiness

Once your service is running behind a cache and handling real traffic, "it works on my machine" stops being useful — you need to be able to tell what a specific request did in production, without SSHing in and guessing. This week replaces plain-text log lines with structured JSON logs carrying a request ID you can grep across an entire trace, splits your one generic /health endpoint into separate liveness and readiness checks that mean different things to your orchestrator, and adds Prometheus metrics that stay bounded in cardinality as your data grows. You'll finish with a service that can actually answer "is it broken, and where" at 3 a.m.

Module 9 of 22 Week 12 of 26 ~3–4 Hours Hands-on Exercise Included

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

  • Emit JSON-structured logs and thread a correlation/request ID through middleware so related log lines group together
  • Separate liveness from readiness checks so an orchestrator only restarts processes that are actually broken
  • Expose Prometheus metrics keyed by route template rather than raw URL, and explain how trace context propagates across service calls

1. Structured Logs & Request IDs

A log line like logging.info(f"Task {task_id} updated") is fine to read in a terminal and useless at scale: once logs are shipped to something like Loki, CloudWatch, or an ELK stack, you want every field — timestamp, level, logger name, the actual message, and any request-specific context — queryable on its own, not buried inside a formatted sentence. A JSON log formatter emits one JSON object per line instead, so "show me every log line for request abc-123" becomes a simple field filter rather than a regex:

app/logging_config.py
import json
import logging
from datetime import datetime, timezone

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "request_id": getattr(record, "request_id", None),
        }
        return json.dumps(payload)

getattr(record, "request_id", None) reads a field that isn't on LogRecord by default — it has to be attached to every record some other way, which is exactly what a request-scoped correlation ID needs. The tricky part in an async app is that a plain module-level variable would leak between concurrent requests being handled interleaved on the same event loop; a contextvars.ContextVar is the async-safe equivalent of "thread-local" storage, correctly isolated per request even as different requests' coroutines take turns running:

app/middleware/request_id.py
import uuid
from contextvars import ContextVar
from starlette.middleware.base import BaseHTTPMiddleware

request_id_var: ContextVar[str] = ContextVar("request_id", default="-")

class RequestIdMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        incoming = request.headers.get("x-request-id")
        request_id = incoming or str(uuid.uuid4())
        request_id_var.set(request_id)

        response = await call_next(request)
        response.headers["X-Request-Id"] = request_id
        return response

A logging.Filter that reads request_id_var.get() and stamps it onto every LogRecord before it reaches the formatter ties the two pieces together: every log statement anywhere in the call stack for that request — in a route, a service function, a repository — automatically carries the same request_id, with no need to thread it through every function signature by hand. Honoring an incoming X-Request-Id header (instead of always minting a fresh one) also lets an upstream gateway or another service propagate its own ID through, so one logical request stays correlated end to end.

2. Liveness & Readiness Checks

A single /health endpoint that just returns 200 OK answers the wrong question the moment you're running behind an orchestrator like Kubernetes. What you actually need are two distinct checks that trigger two distinct actions:

app/routers/health.py
@app.get("/health/live")
async def liveness():
    # No dependency checks here on purpose -- see below.
    return {"status": "alive"}

@app.get("/health/ready")
async def readiness():
    await database.execute(text("SELECT 1"))
    return {"status": "ready"}

Liveness answers "is this process itself still functioning, or is it deadlocked/hung?" If it fails, the orchestrator's only correct response is to kill and restart the container — so a liveness check should touch nothing external. Do not check the database here: if Postgres has a brief network blip, every replica's liveness check fails at once, the orchestrator restarts all of them simultaneously, and you've turned a transient database hiccup into a self-inflicted outage — a restart fixes a hung process, not a database that's temporarily unreachable.

Readiness answers a different question: "can this specific instance serve traffic right now?" That's exactly where dependency checks belong — a failed SELECT 1 means the load balancer should stop routing new requests to this instance without killing it. The process keeps running, keeps retrying its connection to Postgres in the background, and gets added back into rotation automatically the moment /health/ready starts returning 200 again. Same underlying failure, two very different — and both correct — responses, depending on which check you attach it to.

3. Prometheus Metrics & Tracing Context

Prometheus's client library gives you a few metric types; the two you'll reach for constantly are Counter (a number that only goes up, like total requests) and Histogram (which buckets observed values, like request latency, so you can compute percentiles later):

app/observability/metrics.py
from prometheus_client import Counter, Histogram

REQUESTS = Counter(
    "http_requests_total", "Requests", ["method", "route", "status"]
)
LATENCY = Histogram(
    "http_request_duration_seconds", "Request latency", ["method", "route"]
)

The label you pick for route matters enormously. Labeling with the raw request path — /tasks/42, /tasks/43, /tasks/44 — creates a brand-new time series in Prometheus for every distinct task ID that's ever requested, an unbounded and ever-growing set of series called a cardinality explosion; Prometheus (and whatever's storing this data long-term) will eventually choke on it. The fix is labeling with the route template instead — /tasks/{task_id} — which collapses every task lookup into one bounded series no matter how much data exists. FastAPI exposes this through the matched route on the request scope once routing has resolved:

app/middleware/metrics.py
from starlette.middleware.base import BaseHTTPMiddleware
import time

class MetricsMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        elapsed = time.perf_counter() - start

        route = request.scope.get("route")
        route_template = route.path if route else "unmatched"

        REQUESTS.labels(request.method, route_template, response.status_code).inc()
        LATENCY.labels(request.method, route_template).observe(elapsed)
        return response

Note that request.scope["route"] isn't populated until FastAPI's routing has actually matched a handler, which happens inside call_next() — reading it before that call would give you nothing. By the time call_next returns, routing has finished, so it's safe to read there.

Metrics tell you that something is slow; distributed tracing tells you where, across service boundaries. A trace ID identifies one logical end-to-end request; each hop or operation within it (an incoming request, an outbound httpx call to another service from Week 9) gets its own span ID nested under that trace. Propagating a trace context header (the W3C traceparent format is the common standard) on every service-to-service call means that once you view a trace in a tool like Jaeger or Tempo, you see the whole request's path across every service it touched, not just the one that happened to log an error. This course won't stand up a full OpenTelemetry collector, but recognizing what a trace/span ID is for — correlating work across process boundaries the way a request ID correlates it within one — is worth internalizing now.

Why this matters

A request ID and a trace ID solve the same problem at different scopes: one groups your own log lines for a single request, the other groups spans across every service that request touched. Keeping both bounded in cardinality — and never putting a raw ID into a Prometheus label — is what keeps observability tooling useful instead of becoming its own outage.

4. Hands-on Exercise

Hands-on

Make your service observable in production

Add structured logs with correlation IDs, split your health endpoint into liveness and readiness, and expose metrics that stay bounded as your data grows.

Requirements:

  1. Add a JsonFormatter and configure the root logger to use it, so every log line is emitted as a single JSON object.
  2. Add a RequestIdMiddleware that reads or generates a request ID, stores it in a ContextVar, returns it as an X-Request-Id response header, and attach a logging filter that stamps it onto every log record.
  3. Add GET /health/live, returning 200 unconditionally with no dependency checks.
  4. Add GET /health/ready, checking your database (and Redis, if Week 11's cache is still wired in) and returning a non-200 status if either is unreachable.
  5. Add a Counter and Histogram tracking request count and latency, labeled by method, route template (not raw path), and status code, and expose them at GET /metrics.
  6. Hit a couple of parameterized routes with different IDs (e.g. /tasks/1, /tasks/2) and confirm in /metrics that they collapsed into one time series, not two.
Hint

request.scope["route"] is only populated after routing has resolved, so read it after await call_next(request) returns, not before — reading it earlier in the middleware will just give you None.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why should a liveness check not fail merely because the database is briefly unavailable?

A failed liveness check tells the orchestrator to restart the process, but restarting a healthy process can't repair an external dependency that's actually down — it just adds restart churn on top of the outage, and if every replica's liveness check fails from the same database blip, they all restart at once. Dependency checks belong in readiness, where a failure simply removes the instance from load-balancer rotation without killing it.

Q2

Why use a JSON log formatter instead of plain formatted strings in production?

Once logs are shipped to an aggregator like Loki or CloudWatch, a structured JSON object lets every field — timestamp, level, request ID, message — be filtered and queried directly, rather than requiring a regex parse of a formatted sentence. It also makes it trivial to pull every log line for one specific request across an entire service by filtering on request_id, which a plain string log can't offer.

Q3

Why does a request correlation ID need to live in a ContextVar rather than a plain global variable in an async FastAPI app?

A single event loop interleaves many concurrent requests' coroutines, so a plain module-level variable would get overwritten by whichever request set it most recently, leaking one request's ID into another's log lines. A ContextVar is scoped per async context, so each request's coroutine (and anything it awaits) sees only the value it set itself, correctly isolated even though they're all running on the same thread.

Q4

Why is /tasks/42 a bad label value for a Prometheus metric compared to /tasks/{task_id}?

Using the raw path creates a distinct Prometheus time series for every unique task ID ever requested — an unbounded set that only grows as more data accumulates, known as a cardinality explosion, which degrades and can eventually crash the metrics backend. The route template collapses every request to that endpoint into one bounded series regardless of which specific ID was requested, which is what a metrics label is actually meant to represent: the shape of the endpoint, not one instance of it.