Backend Development
Hands-onIntermediate 40 min· Production-ready APIs · 15 of 20

Logging, metrics and health checks

When an API breaks in production you can't attach a debugger. Add structured JSON logs with request IDs, Prometheus metrics and liveness/readiness health checks to a FastAPI app, so you can see what it is doing and why it failed.

On your laptop you find bugs by reading the terminal and adding print(). In production the API runs on a server you can't see, handles many requests at once, and the bug report says only "the app was slow at 9 pm". Observability means your API tells you what it is doing, so you can answer "what happened?" without guessing.

What you'll build: the Brew & Bean API (from *Build an API with Python and FastAPI*) with three additions. JSON logs with a request ID on every line, a /metrics endpoint that counts requests and measures how long they take, and /healthz and /readyz endpoints that a hosting platform can call to check the app.

Prerequisites: Python 3.10 or newer, basic FastAPI (routes, running with uvicorn) and a terminal with curl. Dashboards and alerts on top of these signals are covered in *Monitoring, logs and alerts* in the Cloud & DevOps track.

The three signals

SignalAnswersExample
LogsWhat happened to this one request?POST /orders returned 500 at 21:04 with "KeyError: 'qty'"
MetricsHow is the system doing overall?2% of requests failed in the last 5 minutes; slowest 5% took over 800 ms
Health checksIs this instance alive and able to serve?/readyz returns 503 because the database is down

A fourth signal, tracing, follows one request across several services (API, then payment service, then database). You'll meet it with OpenTelemetry once you have more than one service. For a single API, good logs and metrics cover most problems.

Set up the project

Create the project

  1. 1Make a folder brewbean-obs with a subfolder app and an empty file app/__init__.py.
  2. 2Create a virtual environment: python -m venv .venv, then activate it (source .venv/bin/activate on macOS/Linux, .venv\Scripts\activate on Windows).
  3. 3Install the packages: pip install fastapi "uvicorn[standard]" prometheus-client sqlalchemy.
  4. 4You'll create two files: app/logging_setup.py and app/main.py.

Step 1: structured JSON logs

A plain log line like Order failed for Rahul is easy for you to read but hard to search when there are a million of them. A structured log line is JSON with named fields: {"level": "ERROR", "message": "order failed", "order_id": 42}. Log tools (Grafana Loki, CloudWatch, Render's log view, plain grep with jq) can then filter by any field, for example "all lines where status is 500".

Python's built-in logging module only needs a custom formatter to print JSON. No extra library is needed.

app/logging_setup.py
import json
import logging
import sys
from datetime import datetime, timezone


class JsonFormatter(logging.Formatter):
    def format(self, record):
        entry = {
            "time": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        # Extra fields passed with logger.info("...", extra={"fields": {...}})
        entry.update(getattr(record, "fields", {}))
        if record.exc_info:
            entry["error"] = self.formatException(record.exc_info)
        return json.dumps(entry)


def setup_logging(level="INFO"):
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers = [handler]
    root.setLevel(level)
    # uvicorn prints its own access line per request; ours is richer, so silence it
    logging.getLogger("uvicorn.access").disabled = True

Every log record becomes one line of JSON on standard output. Logging to stdout (not to a file) is the convention for containers and cloud platforms: they collect stdout for you.

Which log level to use

LevelUse it forBrew & Bean example
DEBUGDetail you only want while investigatingThe SQL query built for a menu search
INFONormal, meaningful eventsOrder 42 created, total ₹300
WARNINGSomething odd, but the request still workedPayment webhook retried for the second time
ERRORA request failed because of a bug or a broken dependencyDatabase timeout while saving an order
CRITICALThe whole service can't workCan't read the database password at startup

Never log secrets or personal data

Don't log passwords, tokens, API keys, full card or UPI details, OTPs or whole request bodies. Logs are copied to many places and kept for a long time, and India's DPDP Act expects you to protect personal data. Log an ID (order_id, user_id) instead of the customer's name and phone number, or mask it (98xxxxxx21).

Step 2: a request ID and an access log

When 50 requests run at the same time, their log lines interleave. A request ID is a short random string attached to every line logged during one request, so you can pull out exactly that request's story. If the caller (a frontend or another service) already sent an X-Request-ID header, reuse it so the ID follows the request across systems. Return it in the response too: when a user reports an error, they (or your frontend) can quote the ID.

A FastAPI middleware wraps every request, which makes it the right place to time the request, catch unexpected errors and write one access log line. The same middleware will also record metrics in Step 3, so the full file below already includes them.

app/main.py
import logging
import os
import time
import uuid

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from prometheus_client import Counter, Histogram, make_asgi_app
from sqlalchemy import create_engine, text

from app.logging_setup import setup_logging

setup_logging(os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger("brewbean")

engine = create_engine(os.getenv("DATABASE_URL", "sqlite:///./brewbean.db"))

app = FastAPI(title="Brew & Bean API")

REQUESTS = Counter(
    "http_requests_total", "Total HTTP requests", ["method", "route", "status"]
)
LATENCY = Histogram(
    "http_request_duration_seconds", "Request duration in seconds", ["method", "route"]
)

# /metrics is served by prometheus_client
app.mount("/metrics", make_asgi_app())

MENU = [
    {"id": "espresso", "name": "Espresso", "category": "coffee", "price": 120},
    {"id": "masala-chai", "name": "Masala Chai", "category": "tea", "price": 90},
]


@app.middleware("http")
async def log_requests(request: Request, call_next):
    request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
    request.state.request_id = request_id
    start = time.perf_counter()
    try:
        response = await call_next(request)
    except Exception:
        logger.exception("unhandled error", extra={"fields": {"request_id": request_id}})
        response = JSONResponse({"detail": "Internal server error"}, status_code=500)
    duration = time.perf_counter() - start

    # Use the route template (/orders/{order_id}), not the raw path (/orders/42)
    route = request.scope.get("route")
    route_path = route.path if route else "unmatched"
    if not request.url.path.startswith("/metrics"):
        REQUESTS.labels(request.method, route_path, str(response.status_code)).inc()
        LATENCY.labels(request.method, route_path).observe(duration)

    logger.info(
        "request",
        extra={"fields": {
            "request_id": request_id,
            "method": request.method,
            "path": request.url.path,
            "status": response.status_code,
            "duration_ms": round(duration * 1000, 1),
        }},
    )
    response.headers["X-Request-ID"] = request_id
    return response


@app.get("/menu")
def get_menu():
    return MENU


@app.get("/orders/{order_id}")
def get_order(order_id: int):
    if order_id != 1:
        raise HTTPException(status_code=404, detail="Order not found")
    return {"id": 1, "status": "preparing"}


@app.get("/boom")
def boom():
    raise RuntimeError("something broke")


@app.get("/healthz")
def liveness():
    return {"status": "ok"}


@app.get("/readyz")
def readiness():
    try:
        with engine.connect() as conn:
            conn.execute(text("SELECT 1"))
    except Exception:
        logger.exception("readiness check failed")
        return JSONResponse({"status": "unavailable", "database": "down"}, status_code=503)
    return {"status": "ok", "database": "up"}

The menu and order routes are tiny stand-ins so the file runs on its own; in your real project keep your existing routes and add the middleware, metrics and health routes. /boom exists only to demonstrate an unhandled error: delete it after this lesson.

Why each piece is there:

logger.exception(...) logs at ERROR level and includes the full traceback, so the bug's line number lands in your logs. The client only gets a plain "Internal server error": never send a traceback to the caller, because it reveals file paths and library versions to attackers.

time.perf_counter() is a clock meant for measuring durations. time.time() can jump if the server's clock is adjusted.

HTTPException (the 404) is a normal, handled response, so it does not reach the except block. Only real crashes do.

Terminal
uvicorn app.main:app --port 8000

# in a second terminal
curl -i http://localhost:8000/menu
curl -i http://localhost:8000/orders/7
curl -i http://localhost:8000/boom
curl -i -H "X-Request-ID: abc123" http://localhost:8000/menu

On Windows, use curl.exe in PowerShell so you get the real curl rather than the PowerShell alias.

Server terminal (shortened)
{"time": "2026-10-31T10:20:18.338+00:00", "level": "INFO", "logger": "brewbean", "message": "request", "request_id": "7ad3f0b6d190", "method": "GET", "path": "/menu", "status": 200, "duration_ms": 2.2}
{"time": "2026-10-31T10:20:18.907+00:00", "level": "INFO", "logger": "brewbean", "message": "request", "request_id": "2767d8f09511", "method": "GET", "path": "/orders/7", "status": 404, "duration_ms": 0.7}
{"time": "2026-10-31T10:20:19.186+00:00", "level": "ERROR", "logger": "brewbean", "message": "unhandled error", "request_id": "2ba61036fb9f", "error": "Traceback (most recent call last): ... RuntimeError: something broke"}
{"time": "2026-10-31T10:20:19.249+00:00", "level": "INFO", "logger": "brewbean", "message": "request", "request_id": "2ba61036fb9f", "path": "/boom", "status": 500, ...}
{"time": "2026-10-31T10:20:20.124+00:00", "level": "INFO", "logger": "brewbean", "message": "request", "request_id": "abc123", "path": "/menu", "status": 200, ...}

Notice the ERROR line and the access line for /boom share the same request_id. With real traffic you would search for 2ba61036fb9f and see only that request's lines. The few plain-text lines uvicorn prints at startup ("Uvicorn running on...") come from its own logger setup; that's fine.

To log a business event inside a route, pass extra fields the same way, for example after saving an order: logger.info("order created", extra={"fields": {"order_id": order.id, "total": order.total}}).

Read JSON logs comfortably

Pipe the server output through jq to pretty-print or filter it: uvicorn app.main:app 2>&1 | jq -c 'select(.status >= 500)' shows only failed requests. Hosted platforms let you search on the same fields.

Step 3: metrics with Prometheus

Logs describe single requests. Metrics are numbers aggregated over time, cheap to store and easy to graph and alert on. Prometheus is the most common open-source metrics system: your app exposes current numbers as text at /metrics, and a Prometheus server fetches ("scrapes") that page every few seconds and stores the history.

The middleware above already records two metrics:

A Counter (http_requests_total) only goes up. From it Prometheus computes request rate and error rate (requests with status 5xx divided by all requests).

A Histogram (http_request_duration_seconds) puts each duration into buckets, so you can ask for percentiles such as p95: the time under which 95% of requests finish. Averages hide slow requests; p95 and p99 show what your unluckiest users feel.

Terminal
curl -s http://localhost:8000/metrics/ | grep http_requests_total

Note the trailing slash: the metrics app is mounted, so /metrics answers with a 307 redirect to /metrics/. Use curl -L or the slash.

Output
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/menu",status="200"} 2.0
http_requests_total{method="GET",route="/orders/{order_id}",status="404"} 1.0
http_requests_total{method="GET",route="/boom",status="500"} 1.0

Keep label values few

Every distinct combination of label values is a separate time series that Prometheus stores. That is why the code uses the route template /orders/{order_id} and not the raw path /orders/42: with raw paths, every order ID creates a new series and memory use explodes. Never use user IDs, emails, request IDs or full URLs as label values. Those belong in logs.

The four golden signals to watch

SignalMetric from this lessonA sensible first alert
TrafficRate of http_requests_totalSudden drop to near zero during opening hours
ErrorsShare of requests with status 5xxAbove 1-2% for 5 minutes
Latencyp95 from http_request_duration_secondsp95 above your target (say 500 ms) for 10 minutes
SaturationCPU, memory, database connections (from the platform)Memory above 90% of the limit

The thresholds above are starting points to tune, not rules. Choose them from what your users notice. Prometheus queries (PromQL), Grafana dashboards and alert routing are covered in the DevOps track; for an interview, being able to name these four signals and say why you measure p95 instead of the average is already a strong answer.

Step 4: liveness and readiness checks

Platforms like Kubernetes, Render, AWS load balancers and Docker call a URL on your app every few seconds to decide what to do with it. There are two different questions, so use two endpoints.

Liveness (/healthz): is the process alive and able to answer at all? If this fails repeatedly, the platform restarts the app. Keep it trivial; don't check the database here. Otherwise a short database outage makes the platform restart every API instance in a loop, which makes things worse.

Readiness (/readyz): can this instance serve real traffic right now? It checks the dependencies the app can't work without (here, the database with SELECT 1). If it fails, the platform stops sending traffic to this instance but doesn't restart it. It returns 503 Service Unavailable, the status code that means "temporarily can't serve".

Test both checks

  1. 1With the server running, open http://localhost:8000/healthz and http://localhost:8000/readyz. Both should return "status": "ok".
  2. 2Stop the server. Start it again pointing at a database it can't open: DATABASE_URL=sqlite:///./missing-folder/brewbean.db uvicorn app.main:app --port 8000 (in PowerShell, first run $env:DATABASE_URL="sqlite:///./missing-folder/brewbean.db", then the uvicorn command).
  3. 3Call /healthz again. It still returns 200: the process is alive.
  4. 4Call /readyz. It now returns 503 with "database": "down", and the server log shows an ERROR line with the reason.
  5. 5Stop the server and unset the variable (unset DATABASE_URL, or Remove-Item Env:DATABASE_URL in PowerShell).
curl -i http://localhost:8000/readyz (database unreachable)
HTTP/1.1 503 Service Unavailable
content-type: application/json
x-request-id: 7b69868cabbc

{"status":"unavailable","database":"down"}

Keep health checks cheap

Health checks run every few seconds forever. SELECT 1 is fine; counting every row in a table is not. Don't put secrets or version details in the response, since these endpoints are usually public.

Prompt · Ask AI to explain a production log
You are a senior backend engineer helping me debug a production issue.

Stack: [FastAPI / Express / other], database: [PostgreSQL / SQLite / MongoDB], hosted on [platform].
Symptom: [what users saw, e.g. 500 errors on POST /orders since 9 pm].

Here are the log lines for one failing request (I removed personal data):
---
[paste JSON log lines with the same request_id]
---

1. Explain in plain English what happened, in order.
2. Give the most likely root cause and how confident you are.
3. Tell me what extra log field or metric would have made this easier to diagnose.

What to expect: A good answer walks through the lines in time order, points at the specific line and field that shows the cause, and separates what the logs prove from what it is guessing. Remove names, phones and tokens before pasting.

Try it: Add a business metric and a slow-request warning

1. Add a Counter orders_created_total with one label, category (coffee, tea, bakery, food), and increment it in a POST /orders route for each item ordered. 2. In the middleware, log at WARNING level (with the same fields) when a request takes longer than 500 ms. Test it with a route that calls time.sleep(0.6). 3. Check that /metrics/ shows your new counter.

Production-ready checklist

  1. Logs are JSON, written to stdout, one line per event
  2. Every log line for a request carries the same request ID, and the response returns it
  3. Unhandled errors are logged with a traceback but the client sees a generic message
  4. No passwords, tokens, OTPs or full personal data in logs
  5. Request count and latency histogram exposed at /metrics, labelled by route template
  6. /healthz is trivial; /readyz checks the database and returns 503 when it can't serve

Key takeaways

  1. Logs explain single requests, metrics show trends, health checks tell the platform whether to restart or route traffic.
  2. Structured JSON logs with a request ID let you filter one request's story out of thousands.
  3. Track traffic, errors, latency (p95, not the average) and saturation.
  4. Metric labels must have few possible values: route templates yes, user IDs never.
  5. Liveness stays simple; readiness checks dependencies and returns 503 when they are down.

Check yourself

Finishing the quiz marks the lesson as done.

1. Your API has a sudden spike of 500 errors. A user quotes request ID 9f2c1a from an error page. What lets you find exactly what happened in that request?

2. Why should a liveness check (/healthz) not query the database?

3. A teammate adds a user_id label to http_requests_total. What is the problem?

4. Average response time is 120 ms but users still complain the app is slow. Which number is most useful to look at?

5. What should the client receive when an unhandled exception occurs?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free