◐ ai-teacher

🐍 Backend with Python · Chapter 13

Structured logging, metrics and tracing

At 3 AM you won't have a debugger — only what your service chose to write down, so decide now what it says, in what shape, and how one request's story gets stitched back together.

Study this lesson free in the app →

The basics

Observability is being able to answer "what is my service doing, and why is this request slow?" from the outside, without shipping new code. It rests on three signals.

The problem it solves: in production you can't attach a debugger or add a print — you only have what the service already emits.

The mental model: metrics tell you that something is wrong, traces tell you where, and logs tell you why.

The thread that ties all three together is an ID: a request ID in every log line, a trace ID in every span.

Explained

The courier's tracking number

You send a parcel from Chennai to Guwahati through four hubs.

The tracking number printed on the parcel and passed hub to hub is the traceparent header. A hub that doesn't copy the number onto its own register breaks the chain — and that's the most common way tracing fails in real services.

JSON logs with a request ID in every line

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

class JsonFormatter(logging.Formatter):
    def format(self, record):
        doc = {"level": record.levelname, "logger": record.name,
               "msg": record.getMessage(), "request_id": request_id.get()}
        doc.update(getattr(record, "fields", {}))
        if record.exc_info:
            doc["exc"] = self.formatException(record.exc_info).splitlines()[-1]
        return json.dumps(doc)

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
    token = request_id.set(rid)
    try:
        response = await call_next(request)
    finally:
        request_id.reset(token)
    response.headers["x-request-id"] = rid
    return response

Calling POST /rides with x-request-id: req-7f3a printed:

{"level": "INFO", "logger": "rides", "msg": "fare quoted", "request_id": "req-7f3a", "rider": "asha", "fare_paise": 17000}
{"level": "ERROR", "logger": "rides", "msg": "surge calc failed", "request_id": "req-7f3a", "exc": "ZeroDivisionError: division by zero"}
response header: req-7f3a
{"level": "INFO", "logger": "rides", "msg": "outside any request", "request_id": "-"}

The handler never mentions the ID — the contextvar carries it. A log.debug in the same handler printed nothing: the logger level was INFO.

Why a contextvar, and what to put in a log line

An async server runs hundreds of requests interleaved on one thread. A global CURRENT_REQUEST_ID would be overwritten by whichever request ran last. A ContextVar gives each asyncio task (and each threadpool call FastAPI makes for a sync endpoint — it copies the context) its own value. Always reset(token) in finally.

Levels (numeric): DEBUG 10 < INFO 20 < WARNING 30 < ERROR 40 < CRITICAL 50. Production runs at INFO; a record below the logger's level is dropped before formatting.

A good log line has: an event name that doesn't change ("fare quoted", not an f-string with the fare inside), fields as keys, the request ID, and for errors log.exception(...) so the traceback is captured.

Never log: passwords, tokens, full card numbers, OTPs, Aadhaar numbers. Logs get copied to more places than your database does.

Don't log what a metric should count. "Request served" at INFO on 5,000 req/s is 430 million lines a day — and you still can't graph latency from it cheaply.

The average that lied

1,000 requests: 980 normal (~45 ms) and 20 that hit a slow partner call (1.8–2.6 s). Computed with a nearest-rank percentile:

def pct(data, p):
    s = sorted(data)
    k = max(1, math.ceil(p / 100 * len(s)))
    return s[k - 1]

print("mean", round(statistics.mean(lat), 1))   # mean 88.1

Counter, gauge, histogram — the three metric types

class Counter:
    def inc(self, n=1): self.value += n          # only ever goes up
class Gauge:
    def set(self, v): self.value = v              # goes up and down
class Histogram:                                  # upper bounds in ms, last = +Inf
    def observe(self, v):
        self.counts[bisect.bisect_left(self.buckets, v)] += 1
        self.sum += v

for ms, status in [(31, 200), (48, 200), (52, 200), (40, 500), (900, 200), (2300, 504)]:
    requests.inc()
    if status >= 500: errors.inc()
    latency.observe(ms)
rate: requests = 6
errors: 2 (33%)
duration buckets: {'25': 0, '50': 3, '100': 1, '250': 0, '500': 0, '1000': 1, '+Inf': 1}
in_flight gauge: 3

That's RED for one endpoint: Rate (counter, graphed as per-second change), Errors (counter of 5xx), Duration (histogram). For resources — CPU, the DB pool, a queue — use USE: Utilisation, Saturation (how much is waiting), Errors.

Why histograms and not stored percentiles: bucket counts from 40 pods add up; p99s from 40 pods don't. Prometheus stores buckets cumulatively (le="50" includes everything ≤ 50) and estimates quantiles from them; the idea is the same.

Spans: a request as a tree of timings

Real output: db.select rider 13 ms, http payments.charge 185 ms, POST /rides 198 ms. One glance: 93% of this request is the payment call. OpenTelemetry is the standard that does exactly this for you — spans, parent links, export to a backend — through instrumentation packages for FastAPI, httpx and SQLAlchemy. It isn't installed here, so this is the mechanism in plain Python, not its API.

Crossing services: the traceparent header

Inside one process the parent link lives in a contextvar. Across a network call it travels in the W3C Trace Context header:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ││ └──────── trace-id (32 hex) ─────┘ └ parent-id (16) ┘ └ flags
             └ version
def child_traceparent(incoming):
    m = TRACEPARENT.match(incoming)
    if not m:
        return new_traceparent()              # bad header: start a fresh trace
    trace_id, _parent_span, flags = m.groups()
    return f"00-{trace_id}-{secrets.token_hex(8)}-{flags}"
00-4bf92f3577b34da6a3ce929d0e0e4736-3d898714fad67daf-01
same trace: True
new span:   True
55 00-

Log the trace-id as a field too, and you can jump from a slow span straight to its log lines.

Sunday 26 May 2024, 9:10 PM — IPL final night

Food-delivery checkout. The dashboard's average latency reads 140 ms, flat. Support has 600 tickets saying "payment stuck".

  1. Metrics: p50 = 120 ms, but p99 = 8.2 s, starting 21:04. Error rate 0.4% → 3.1%. The DB pool gauge sits at 50/50 — saturated (the S in USE).
  2. Traces: sample a slow checkout. The tree shows POST /checkout 8.1 s, of which http wallet.debit is 7.9 s — the wallet partner, not us.
  3. Logs: filter trace_id = … → {"msg": "wallet debit timeout", "attempt": 3}. We retry three times, each holding a DB connection while waiting. That's why the pool is full and unrelated endpoints slowed too.

Fix that night: release the DB connection before the partner call, and cap the partner timeout at 2 s (next lesson). Without percentiles, a trace and a request-scoped log line, this is three hours of guessing instead of eleven minutes.

Words to know

Structured logA log record emitted as machine-readable fields (usually JSON) rather than a free-text sentence.
ContextVarA variable whose value is local to the current asyncio task or copied context — safe per-request state under concurrency.
HistogramA metric that counts observations into buckets plus a running sum; mergeable across instances, used to estimate percentiles.
p99The value 99% of observations are at or below — the latency your slowest 1-in-100 users see.
RED / USERED (rate, errors, duration) for request-driven services; USE (utilisation, saturation, errors) for resources like pools and CPUs.
SpanOne timed operation in a trace, with its own id, the trace id and its parent span's id.
traceparentW3C header: version-traceid(32 hex)-parentid(16 hex)-flags, carrying trace context between services.

In two minutes

Test yourself

1. What does each part of traceparent: 00-4bf9…4736-00f0…02b7-01 hold?

  1. Status code, user id, session id, retry count
  2. Version, request id, user id, log level
  3. Version, 32-hex trace-id, 16-hex parent span id, flags (01 = sampled)
  4. Version, span id, trace id, timeout
Show the answer

Version, 32-hex trace-id, 16-hex parent span id, flags (01 = sampled) — The trace-id stays the same across every service; the parent-id is the caller's span; flags carry the sampling decision. It carries no user data.

2. Why alert on p99 latency rather than the average?

  1. The average hides the slow tail; in our sample the mean was 88.1 ms while p50 was 45.0 and p99 was 2154.7
  2. Averages are expensive to compute
  3. Prometheus can't compute averages
  4. p99 is always lower than the average
Show the answer

The average hides the slow tail; in our sample the mean was 88.1 ms while p50 was 45.0 and p99 was 2154.7 — A small slow group drags the mean up for everyone while still hiding how bad it is for them. Percentiles describe real requests.

3. Why store the request ID in a ContextVar rather than a module-level global?

  1. Globals can't hold strings
  2. ContextVars are faster
  3. logging only reads ContextVars
  4. Concurrent requests interleave on one thread, so a global gets overwritten by whichever request ran last; a ContextVar is per task
Show the answer

Concurrent requests interleave on one thread, so a global gets overwritten by whichever request ran last; a ContextVar is per task — Under async, request A can await while request B sets the global; A's next log line would carry B's ID. FastAPI copies the context into the threadpool for sync endpoints too — our sync handler logged the right ID.

4. How does the request ID end up in every log line without the handler passing it around?

  1. Python's logging adds it automatically
  2. Middleware reads or creates it, sets a ContextVar for the request, and the formatter reads that ContextVar when formatting each record
  3. The client sends it in every log call
  4. uvicorn injects it into print()
Show the answer

Middleware reads or creates it, sets a ContextVar for the request, and the formatter reads that ContextVar when formatting each record — The handler stays clean. The middleware also echoes it in the response header so a user can quote it in a support ticket.

5. RED vs USE — which pairing is right?

  1. RED for CPUs, USE for HTTP endpoints
  2. Both mean the same thing
  3. RED (rate, errors, duration) for request-driven services; USE (utilisation, saturation, errors) for resources like DB pools
  4. RED is for logs, USE is for traces
Show the answer

RED (rate, errors, duration) for request-driven services; USE (utilisation, saturation, errors) for resources like DB pools — An endpoint has requests and latency; a connection pool has capacity and a queue. Saturation — how much is waiting — is the one teams forget.

6. What happens to log.debug("cache lookup detail") when the logger level is INFO?

  1. It is dropped — DEBUG (10) is below INFO (20)
  2. It prints with level INFO
  3. It raises a warning
  4. It is buffered and printed at shutdown
Show the answer

It is dropped — DEBUG (10) is below INFO (20) — Records below the logger's level are discarded before formatting. Our run printed the INFO and ERROR lines only.

There are 10 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →