Prometheus Metrics for FastAPI
Key takeaways:
- RED — Rate, Errors, Duration — is three metrics: a request counter, the status label on that counter, and a latency histogram.
- Label with the route template (
/users/{user_id}), never the raw path. This is the mistake that takes Prometheus down. - Read
request.scope["route"]aftercall_next, or the router has not matched yet and you get the raw path anyway. - Pick histogram buckets around your SLO; the defaults are not tuned for your service.
- With multiple workers, an in-process registry only ever reports one worker's numbers.
You can see that the service is slow but not which endpoint, or you have an error budget and nothing that measures it. This page — part of Observability and Tracing — covers exporting Prometheus metrics from FastAPI, and spends most of its length on the label design, because that is where production incidents come from.
What to Measure
The RED method gives you three signals per endpoint, and they answer nearly every question you have during an incident. Rate is how many requests per second an endpoint is serving. Errors is how many of them failed. Duration is the latency distribution.
In Prometheus terms that is two metrics, not three. A counter with a status label gives you rate (its increase) and errors (the increase filtered to 5xx), and a histogram gives you duration. Everything else you might add — in-flight gauges, response size summaries, dependency call counters — is refinement on top of those two.
Histograms rather than summaries, for one specific reason: Prometheus can aggregate histogram buckets across instances, and cannot aggregate summary quantiles. A p99 computed per pod and then averaged is a meaningless number. histogram_quantile() over summed buckets is a real one.
The Instrumentation
prometheus-client provides the metric types and the exposition format. Everything below is verified against version 0.25.0 with FastAPI 0.139.2.
import os
import time
# Off before prometheus_client is imported: _created series carry wall-clock timestamps, which
# would make every scrape differ. Real deployments usually turn them off too.
os.environ.setdefault("PROMETHEUS_DISABLE_CREATED_SERIES", "true")
from fastapi import FastAPI, HTTPException, Request, Response
from prometheus_client import (
CONTENT_TYPE_LATEST,
CollectorRegistry,
Counter,
Histogram,
generate_latest,
)
REGISTRY = CollectorRegistry()
REQUESTS_TOTAL = Counter(
"http_requests_total",
"Total HTTP requests.",
["method", "path", "status"],
registry=REGISTRY,
)
REQUEST_DURATION = Histogram(
"http_request_duration_seconds",
"Request latency.",
["method", "path"],
# Buckets chosen for a web API: dense where your SLO lives, not the library default.
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
registry=REGISTRY,
)
An explicit CollectorRegistry rather than the module-level default keeps the process's own Python and platform collectors out of your app metrics, and — more practically — makes the registry something you can throw away between tests instead of fighting duplicate-timeseries errors on re-import.
The middleware records both metrics and resolves the label:
# Fixed latencies so the published scrape is byte-stable; production uses the real elapsed time.
_TICKS = iter([0.004, 0.030, 0.220, 0.700])
app = FastAPI()
def route_template(request: Request) -> str:
"""The label that keeps cardinality bounded: '/users/{user_id}', never '/users/8342'."""
route = request.scope.get("route")
return getattr(route, "path", request.url.path)
@app.middleware("http")
async def record_metrics(request: Request, call_next):
start = time.perf_counter()
try:
response = await call_next(request)
status = response.status_code
except Exception:
REQUESTS_TOTAL.labels(request.method, route_template(request), "500").inc()
raise
elapsed = time.perf_counter() - start
elapsed = next(_TICKS, 0.012) # Delete this line outside the docs: it fakes the clock.
path = route_template(request)
if path != "/metrics":
REQUESTS_TOTAL.labels(request.method, path, str(status)).inc()
REQUEST_DURATION.labels(request.method, path).observe(elapsed)
return response
@app.get("/metrics")
async def metrics() -> Response:
return Response(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
Two details in that middleware are load-bearing. The route_template call happens after await call_next(request), because Starlette's router writes scope["route"] when it matches — read it before and you are reading a key that does not exist yet, silently falling back to the raw path and creating the cardinality explosion you were trying to avoid. And the except branch counts a 500 before re-raising, so unhandled exceptions appear in the error rate rather than vanishing from it. That branch matters more than it looks: without it, the metric that drives your alerting goes quiet exactly when the service starts crashing.
The Real Scrape
Here is the actual output of running that app with three successful requests, one 404, and then a scrape. The latencies are fixed in the example so the transcript is byte-stable; everything else is what the library emitted.
$ GET /users/999
404 Not Found
{
"detail": "no such user"
}
$ GET /metrics
200 OK
# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/users/{user_id}",status="200"} 3.0
http_requests_total{method="GET",path="/users/{user_id}",status="404"} 1.0
# HELP http_request_duration_seconds Request latency.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.005",method="GET",path="/users/{user_id}"} 1.0
http_request_duration_seconds_bucket{le="0.01",method="GET",path="/users/{user_id}"} 1.0
http_request_duration_seconds_bucket{le="0.025",method="GET",path="/users/{user_id}"} 1.0
http_request_duration_seconds_bucket{le="0.05",method="GET",path="/users/{user_id}"} 2.0
http_request_duration_seconds_bucket{le="0.1",method="GET",path="/users/{user_id}"} 2.0
http_request_duration_seconds_bucket{le="0.25",method="GET",path="/users/{user_id}"} 3.0
http_request_duration_seconds_bucket{le="0.5",method="GET",path="/users/{user_id}"} 3.0
http_request_duration_seconds_bucket{le="1.0",method="GET",path="/users/{user_id}"} 4.0
http_request_duration_seconds_bucket{le="2.5",method="GET",path="/users/{user_id}"} 4.0
http_request_duration_seconds_bucket{le="5.0",method="GET",path="/users/{user_id}"} 4.0
http_request_duration_seconds_bucket{le="+Inf",method="GET",path="/users/{user_id}"} 4.0
http_request_duration_seconds_count{method="GET",path="/users/{user_id}"} 4.0
http_request_duration_seconds_sum{method="GET",path="/users/{user_id}"} 0.954
Four requests to four different URLs produced exactly two counter series and one histogram, because the label is the template. The 404 is its own series rather than being folded into the 200s, which is what lets you alert on error ratio without alerting on traffic.
The histogram is cumulative — each le bucket counts every observation at or below that boundary, which is why the numbers only ever increase down the list and +Inf equals _count. The four requests took 4ms, 30ms, 220ms and 700ms, and you can read that straight off the buckets: one under 5ms, one more by 50ms, a third by 250ms, the last by 1s. That is the whole point of picking boundaries near your SLO — with the wrong buckets, all four observations land in one bucket and histogram_quantile has nothing to interpolate between.
Cardinality Is the Production Failure
Every unique combination of label values is a separate time series, stored, indexed and held in memory by Prometheus. A metric with a path label taking a million values is a million series from one endpoint on one instance. Multiply by replicas. This is the single most common way a well-meaning metrics change takes down a monitoring stack, and the symptom is usually Prometheus OOMing rather than anything visibly wrong with your service.
The rules that keep it bounded:
- Never label with anything user-supplied. No user ids, no order ids, no email addresses, no full URLs, no query strings, no free-text error messages. If you need per-user detail, that is a log line or a trace span, not a metric.
- Label with the route template. Bounded by the number of routes in your code, which is a number you control.
- Watch out for 404s on unmatched paths. When the router does not match,
scope["route"]is absent and the fallback returns the raw path — which is attacker-controlled. Scanners probing random URLs will happily mint you a series per probe. If your fallback can see unmatched requests, emit a constant such as"<unmatched>"instead. - Keep status as the code, not the reason phrase. Three digits, bounded; and consider bucketing to
2xx/4xx/5xxif you do not need the detail.
A quick sanity check on any candidate label: can a client change its value? If yes, it does not belong in a metric.
Multiple Workers
The registry above lives in one Python process. Run uvicorn with four workers and Prometheus scrapes whichever worker the load balancer picks, getting a quarter of the traffic and none of the others — counters that appear to jump backwards as different workers answer successive scrapes.
Two ways out. prometheus-client ships a multiprocess mode where each worker writes to memory-mapped files in a shared directory (PROMETHEUS_MULTIPROC_DIR) and the scrape endpoint aggregates across them; it works but it constrains which metric types behave sensibly, and it needs the directory cleared between deploys. The alternative, which is what most Kubernetes deployments do, is one worker per container and one scrape target per pod, letting Prometheus do the aggregation it is designed for.
Verification
The metrics endpoint is testable like anything else, and the assertion worth making is about label shape rather than values:
def test_metrics_use_route_templates(client):
client.get("/users/8342")
body = client.get("/metrics").text
assert 'path="/users/{user_id}"' in body # Templated.
assert "8342" not in body # No user id leaked into a label.
def test_errors_are_counted(client):
client.get("/users/999")
body = client.get("/metrics").text
assert 'status="404"' in body
The second assertion in the first test is the one to copy into every service. It is a cardinality regression test, and it fails the moment someone adds a well-intentioned user_id label.
Beyond tests, run promtool check metrics against a scrape in CI to catch naming problems — Prometheus convention is a base unit suffix (_seconds, _bytes, _total), and a metric named request_time_ms will quietly confuse everyone who queries it.
Trade-offs and When Not To
The middleware adds a fixed cost to every request: a perf_counter pair, a label lookup, and two atomic increments. That is small, but it is not free, and it is paid on the hot path.
Metrics are aggregates, and aggregates lose the individual request. When your alert fires, the metric tells you that the p99 on one route degraded; it cannot tell you which requests were slow or why. That is what traces and logs are for, and the way to move between them is a shared request id, covered in correlating logs, traces and errors and structured JSON logging with request IDs.
If you are already running OpenTelemetry, consider its metrics API instead of a second instrumentation layer — see instrumenting FastAPI with OpenTelemetry. One pipeline emitting both traces and metrics with consistent attributes is easier to operate than two libraries with two label vocabularies that almost agree.
Finally, do not expose /metrics publicly. The endpoint enumerates every route you serve, how much traffic each gets, and how often each fails — a free reconnaissance report. Internal port, ingress rule, or a dependency that checks a scrape token.
FAQ
Why must I label metrics with the route template instead of the URL path? Because every distinct label value creates a separate time series. Labelling with the raw path creates one series per user id, so a million users become a million series and the scrape eventually kills Prometheus. The template /users/{user_id} is one series.
How do I get the route template inside middleware? Read request.scope'route'.path after the router has matched, which means after await call_next(request). Before that point the scope has no route key, so a metrics middleware that reads it too early sees only the raw path.
What histogram buckets should I use for request latency? Buckets that straddle your SLO, not the library defaults. If your target is 250ms, include boundaries around it so the quantile you care about is interpolated from a dense region rather than a wide bucket.
Do metrics work with multiple uvicorn workers? Not with the default in-process registry, because each worker has its own counters and a scrape reaches only one of them. Use prometheus-client's multiprocess mode with a shared directory, or scrape each worker on its own port.
Should the /metrics endpoint be public? No. It reveals your route inventory, traffic volumes and error rates. Bind it to an internal port, restrict it at the ingress, or protect it with a dependency that checks a scrape credential.
Related Reading
- Up to the topic: Observability and Tracing.
- Traces alongside metrics: Instrumenting FastAPI with OpenTelemetry.
- From an alert to a request: Correlating logs, traces and errors.
- The log substrate: Structured JSON logging with request IDs.