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:
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:
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.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):
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:
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.
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
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:
- Add a
JsonFormatterand configure the root logger to use it, so every log line is emitted as a single JSON object. - Add a
RequestIdMiddlewarethat reads or generates a request ID, stores it in aContextVar, returns it as anX-Request-Idresponse header, and attach a logging filter that stamps it onto every log record. - Add
GET /health/live, returning200unconditionally with no dependency checks. - 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. - Add a
CounterandHistogramtracking request count and latency, labeled by method, route template (not raw path), and status code, and expose them atGET /metrics. - Hit a couple of parameterized routes with different IDs (e.g.
/tasks/1,/tasks/2) and confirm in/metricsthat they collapsed into one time series, not two.
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?
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?
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?
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}?
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.