Module 12 · Production Engineering · Deep Dive

Observability: Logs, Metrics & Traces

When DocChat is slow at 2am and a customer in Dubai is angry, you don't get to attach a debugger. You get whatever your past self decided to record. This lesson is how your past self saves you.

BasicIntermediateBuild

Why this matters A working app on your laptop is monitoring. A production app you can ask questions of — "why was this one /ask request slow?", "which user hit the error?", "how much did that LLM call cost?" — is observable. The difference is whether you ship logs, metrics, and traces on purpose. Interviewers probe this hard because it separates people who've run things in prod from people who've only built them. By the end you'll instrument DocChat's /ask so a single slow request leaves a trail you can follow end to end.
In this lesson
  1. The three pillars & what each answers
  2. Structured logging with structlog
  3. Request-id & logging middleware
  4. Error tracking with Sentry
  5. Metrics, the RED method & Prometheus
  6. Distributed tracing with OpenTelemetry
  7. Health, readiness & LLM cost metrics
  8. Alerting on symptoms, not noise
  9. Build: instrument DocChat's /ask
  10. Check yourself

1 · The three pillars

Observability rests on three kinds of telemetry. They overlap, but each answers a different question, and a mature system emits all three.

PillarAnswersShape
Logs"What exactly happened in this request?"Discrete, timestamped events — ideally one JSON object per line.
Metrics"How is the system behaving in aggregate right now?"Numbers over time — request rate, p95 latency, error percentage.
Traces"Where did the time go across services?"A tree of timed spans following one request through every hop.

A useful mental model: metrics tell you something is wrong (errors spiking), traces tell you where (the LLM call took 9s), and logs tell you why (the prompt was 30k tokens because retrieval returned the whole document). You move metrics → traces → logs as you zoom in.

From your scripting days: print() and var_dump() are logs — just the worst possible kind. The rest of this lesson is upgrading from "I printed something somewhere" to telemetry you can actually query.

2 · Structured logging with structlog

A plain log line is a sentence written for a human staring at a terminal. In production, nothing stares at a terminal — a log aggregator (Loki, CloudWatch, Datadog) ingests millions of lines and you query them. For that, every log must be a machine-readable object with named fields.

# BAD — you can't query "all requests slower than 2s" from this
print(f"request to /ask took {duration}s for user {user_id}")

# GOOD — every field is queryable, filterable, aggregatable
log.info("request_completed", path="/ask", duration_ms=1840, user_id=user_id)
# {"event": "request_completed", "path": "/ask", "duration_ms": 1840, ...}

In June 2026 the standard tool for this in Python is structlog. It wraps the stdlib logger, lets you attach context as keyword arguments, and renders JSON in production. Configure it once at startup:

app/logging.py
import logging
import structlog

def configure_logging(json_logs: bool = True) -> None:
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,   # pull in request_id etc.
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            # JSON in prod; pretty colours when developing locally
            structlog.processors.JSONRenderer() if json_logs
                else structlog.dev.ConsoleRenderer(),
        ],
        wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
        cache_logger_on_first_use=True,
    )

log = structlog.get_logger()

The payoff is contextvars. Bind a value once — a request_id, the user_id — and every log line emitted anywhere downstream in that request automatically carries it. No threading it through twenty function arguments.

from structlog.contextvars import bind_contextvars, clear_contextvars

bind_contextvars(request_id="req_8fa3", user_id="u_42")
log.info("retrieval_started")      # carries request_id + user_id
log.info("llm_call_started")       # so does this — for free
Log levels, on purpose DEBUG = noisy detail you'd only want when hunting a bug. INFO = normal lifecycle events ("request_completed"). WARNING = something recoverable but odd (retried the LLM call). ERROR = a request failed. CRITICAL = the process is in danger. In prod you usually run at INFO and turn on DEBUG for one service when you're investigating. Never log secrets, full prompts with PII, or raw API keys.

3 · Request-id & logging middleware

The single highest-value thing you can do is give every request a correlation id and stamp it on every log line. Then "show me everything that happened in that one slow request" is a single filter: request_id = "req_8fa3". FastAPI middleware is the place to do this — it wraps every route.

app/middleware.py
import time
import uuid
from structlog.contextvars import bind_contextvars, clear_contextvars
from starlette.middleware.base import BaseHTTPMiddleware
from app.logging import log

class LoggingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        clear_contextvars()
        # trust an upstream id (load balancer / gateway) or mint one
        request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
        bind_contextvars(request_id=request_id,
                         method=request.method,
                         path=request.url.path)
        start = time.perf_counter()
        try:
            response = await call_next(request)
        except Exception:
            duration_ms = (time.perf_counter() - start) * 1000
            log.exception("request_failed", duration_ms=round(duration_ms, 1))
            raise
        duration_ms = (time.perf_counter() - start) * 1000
        log.info("request_completed",
                 status_code=response.status_code,
                 duration_ms=round(duration_ms, 1))
        response.headers["x-request-id"] = request_id   # hand it back to the client
        return response

Now one line per request records method, path, status, and duration — already enough to answer "what are my slowest endpoints?" And because request_id is in contextvars, the retrieval log, the LLM-call log, and any error all share it. Returning it in the response header means a support ticket can quote the id and you jump straight to the trail.

Interview hook: "How do you debug a slow endpoint in prod?" The answer that lands: "I'd pull the metric for p95 latency on that route to confirm it's real, find a slow request's trace to see which span dominates, then grep the logs by its request_id to read the detail." That's metrics → traces → logs, and it's exactly what you're building here.

4 · Error tracking with Sentry

Logs scroll past. An error deserves to be caught, grouped with its identical siblings, counted, and surfaced with a full stack trace and the surrounding context. That's error tracking, and Sentry is the standard. Its FastAPI integration captures unhandled exceptions automatically.

app/main.py
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration

sentry_sdk.init(
    dsn=settings.SENTRY_DSN,
    environment=settings.ENV,            # "production" / "staging"
    release=settings.GIT_SHA,            # tie errors to a deploy
    traces_sample_rate=0.1,             # sample 10% for performance
    send_default_pii=False,            # don't ship user data by default
    integrations=[StarletteIntegration(), FastApiIntegration()],
)

Unhandled exceptions now appear in Sentry without another line of code. But the gold is context: attach the values that make an error reproducible before you re-raise or capture.

with sentry_sdk.new_scope() as scope:
    scope.set_tag("feature", "ask")
    scope.set_context("retrieval", {"doc_id": doc_id, "k": top_k})
    scope.set_user({"id": user_id})           # grouped, searchable
    try:
        answer = await run_ask(question, doc_id)
    except LLMTimeout as e:
        sentry_sdk.capture_exception(e)        # with all the context above
        raise
Releases & source maps for the Next.js frontend Set release to your git SHA on both backend and frontend so Sentry shows "this error started in deploy a1b2c3" — often the fastest root cause. For the Next.js app, use @sentry/nextjs; during the build it uploads source maps so a minified frontend stack trace turns back into your real .tsx file names and line numbers. Without source maps a browser error reads like gibberish.

5 · Metrics, the RED method & Prometheus

Metrics are cheap numbers over time, and they come in three flavours. Get these straight — it's a common interview gap.

TypeMeaningExample
CounterOnly goes up. You graph its rate.http_requests_total
GaugeGoes up and down — a current value.active_connections
HistogramBuckets observations so you can compute percentiles.request_duration_seconds

For request-serving systems the RED method tells you which metrics actually matter:

Expose them on a /metrics endpoint that Prometheus scrapes every few seconds.

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

REQUESTS = Counter(
    "http_requests_total", "Total HTTP requests",
    ["method", "path", "status"],          # labels = dimensions to slice by
)
LATENCY = Histogram(
    "http_request_duration_seconds", "Request latency",
    ["method", "path"],
)

# mount at /metrics — Prometheus scrapes this
metrics_app = make_asgi_app()
# in the middleware, after timing the request:
REQUESTS.labels(request.method, route, response.status_code).inc()
LATENCY.labels(request.method, route).observe(duration_ms / 1000)
The cardinality trap Never use a high-uniqueness value (a user_id, a request_id, a raw URL with ids in it) as a metric label. Each label combination is a separate time series; millions of them will melt Prometheus. Normalise paths to the route template (/docs/{id}, not /docs/8412). High-uniqueness data belongs in logs and traces, not metric labels.

Then Grafana reads Prometheus and draws the dashboard: a graph of request rate, an errors-per-second panel, and a p95-latency line per route. A good DocChat dashboard puts RED for /ask top-left so one glance tells you if the service is healthy.

6 · Distributed tracing with OpenTelemetry

A trace follows one request as a tree of spans. The root span is the whole /ask request; child spans are the steps inside it — the DB query for retrieval, the outbound HTTP call to the LLM, the post-processing. Each span has a start, a duration, and attributes. Seeing them stacked tells you instantly where the 9 seconds went.

OpenTelemetry (OTel) is the vendor-neutral standard, and June 2026's default. The magic is auto-instrumentation: install the integrations and FastAPI, SQLAlchemy, and outbound HTTP get traced with zero application code.

app/tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

def configure_tracing(app, engine) -> None:
    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
    trace.set_tracer_provider(provider)

    FastAPIInstrumentor.instrument_app(app)        # every route → a root span
    SQLAlchemyInstrumentor().instrument(engine=engine)   # every query → a span
    HTTPXClientInstrumentor().instrument()         # every outbound call → a span

tracer = trace.get_tracer("docchat")

Context propagation is what makes it "distributed": OTel injects a traceparent header on outbound calls so the next service continues the same trace instead of starting a new one. One id links the whole journey across process boundaries.

Auto-instrumentation won't know about your business steps, though — like the LLM call. Wrap those in a manual span and tag it with the attributes you'll want to filter on:

with tracer.start_as_current_span("llm.chat") as span:
    span.set_attribute("llm.model", model)
    span.set_attribute("llm.prompt_tokens", prompt_tokens)
    answer = await client.messages.create(...)   # the slow part, now visible
    span.set_attribute("llm.completion_tokens", answer.usage.output_tokens)
Tie it together: put the trace_id into your structlog context too. Then a log line, a Sentry error, and a trace all share one id — click from any pillar to any other.

7 · Health, readiness & LLM cost metrics

Orchestrators (Kubernetes, ECS, a load balancer) need to ask your service two different questions, so expose two endpoints:

@app.get("/healthz")            # LIVENESS: is the process alive at all?
async def healthz():
    return {"status": "ok"}     # cheap, no dependencies — never restart on a DB blip

@app.get("/readyz")             # READINESS: can it serve traffic right now?
async def readyz():
    await db.execute(text("SELECT 1"))     # check real dependencies
    return {"status": "ready"}

Liveness failing means "restart me". Readiness failing means "stop sending me traffic until I recover" — distinct actions, which is why they're separate endpoints.

Now the bit unique to AI products: treat LLM cost and tokens as first-class metrics. They're a real line on your AWS-and-Anthropic bill and the thing finance asks about. Record them like any other RED-style metric:

from prometheus_client import Counter, Histogram

LLM_TOKENS = Counter("llm_tokens_total", "Tokens used", ["model", "kind"])
LLM_COST = Counter("llm_cost_usd_total", "LLM spend in USD", ["model"])
LLM_LATENCY = Histogram("llm_call_seconds", "LLM call latency", ["model"])

def record_llm(model, in_tok, out_tok, cost_usd, seconds):
    LLM_TOKENS.labels(model, "input").inc(in_tok)
    LLM_TOKENS.labels(model, "output").inc(out_tok)
    LLM_COST.labels(model).inc(cost_usd)
    LLM_LATENCY.labels(model).observe(seconds)

Graph llm_cost_usd_total in Grafana and you can answer "what does a single /ask cost us on average?" and catch a runaway prompt the day it ships, not when the invoice lands.

8 · Alerting on symptoms, not noise

An alert that fires when nothing is actually wrong trains everyone to ignore alerts — and the night something is wrong, nobody looks. The discipline: alert on user-visible symptoms tied to your SLOs, not on internal causes.

Anchor thresholds to a Service Level Objective — e.g. "99% of /ask requests succeed within 5s over 30 days." Your alert fires when you're burning that error budget too fast. Use causes (CPU, queue depth) as dashboard signals you check after a symptom alert wakes you, not as pagers themselves.

The on-call sniff test For every alert ask: "If this fires at 3am, is there a human action to take right now?" If the honest answer is "no, I'd just acknowledge and go back to sleep" — it's a dashboard metric, not an alert. Ruthlessly demote those.

9 · Build it

Your tangible win Fully instrument DocChat's /ask: a request id on every structured log, a Sentry capture with context on failure, an OpenTelemetry trace spanning retrieval → LLM call → response, and a token/cost log line. After this, a slow or failed /ask is fully explainable from telemetry alone.

Assuming logging, tracing, Sentry, and the metrics are configured at startup (sections 2–7), the route itself ties the pillars together:

app/routes/ask.py
import time
import sentry_sdk
from opentelemetry import trace
from app.logging import log
from app.metrics import record_llm

tracer = trace.get_tracer("docchat")

@router.post("/ask")
async def ask(body: AskIn, user=Depends(current_user)):
    # request_id is already bound by LoggingMiddleware (section 3)
    log.info("ask_started", doc_id=body.doc_id, q_len=len(body.question))

    with tracer.start_as_current_span("ask") as root:
        root.set_attribute("doc_id", body.doc_id)
        try:
            # --- retrieval span (child) ---
            with tracer.start_as_current_span("retrieval") as rspan:
                chunks = await retrieve(body.doc_id, body.question, k=5)
                rspan.set_attribute("chunks", len(chunks))
                log.info("retrieval_done", chunks=len(chunks))

            # --- LLM span (child) with cost + tokens ---
            with tracer.start_as_current_span("llm.chat") as lspan:
                lspan.set_attribute("llm.model", MODEL)
                t0 = time.perf_counter()
                resp = await client.messages.create(
                    model=MODEL,
                    max_tokens=1024,
                    messages=build_prompt(chunks, body.question),
                )
                seconds = time.perf_counter() - t0
                in_tok = resp.usage.input_tokens
                out_tok = resp.usage.output_tokens
                cost = price(MODEL, in_tok, out_tok)   # your pricing table
                lspan.set_attribute("llm.prompt_tokens", in_tok)
                lspan.set_attribute("llm.completion_tokens", out_tok)
                lspan.set_attribute("llm.cost_usd", cost)

            # --- first-class cost telemetry ---
            record_llm(MODEL, in_tok, out_tok, cost, seconds)
            log.info("ask_completed",
                     model=MODEL, prompt_tokens=in_tok,
                     completion_tokens=out_tok, cost_usd=round(cost, 5),
                     llm_seconds=round(seconds, 2))
            return {"answer": resp.content[0].text}

        except Exception as e:
            with sentry_sdk.new_scope() as scope:
                scope.set_tag("feature", "ask")
                scope.set_context("ask", {"doc_id": body.doc_id})
                scope.set_user({"id": user.id})
                sentry_sdk.capture_exception(e)
            root.record_exception(e)            # mark the trace as errored too
            log.exception("ask_failed", doc_id=body.doc_id)
            raise HTTPException(502, "answer generation failed")

Now trace a slow /ask the way you would on the job: the Grafana RED panel shows p95 climbing, you open a slow trace and see the llm.chat span is 8s while retrieval is 40ms, you grep logs by that request_id and the ask_completed line shows prompt_tokens: 31000 — retrieval handed the model too much context. Three pillars, one story, root cause in minutes.

10 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

Which pillar best answers "where did the time go across the request?"

Why prefer JSON logs over plain printed sentences in production?

What does the RED method stand for?

Which value must you NOT use as a Prometheus metric label?

Which makes the best production alert?

Primary source ⭐ OpenTelemetry — Python documentation, the authoritative reference for traces, spans, and auto-instrumentation. Pair it with structlog docs for structured logging and Sentry's FastAPI guide for error tracking.