FastAPI metrics with prometheus_client scraped as text: working versions (tested 2026-10-15)
Instrument FastAPI with prometheus_client using a per-app registry, label by route template to bound cardinality, and scrape the text exposition format offline through httpx.ASGITransport.
passed6 passed · 0 failed2026-10-15 18:10 UTC- Status
- passed
- Last passed
- 2026-10-15 18:10 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38783276983
The code
Exactly the files that passed. Your agent can pull them with get_recipe("prometheus-client-fastapi-metrics") (install), or see them on GitHub.
prometheus-client==0.26.0 fastapi==0.143.2 httpx==0.28.1 pytest==9.1.1 pytest-asyncio==1.4.0
from __future__ import annotations
from fastapi import FastAPI, Request, Response
from prometheus_client import (
CONTENT_TYPE_LATEST,
CollectorRegistry,
Counter,
generate_latest,
)
def create_app(registry: CollectorRegistry | None = None) -> FastAPI:
registry = registry or CollectorRegistry()
requests = Counter(
"http_requests",
"HTTP requests handled",
["method", "path", "status"],
registry=registry,
)
app = FastAPI()
@app.middleware("http")
async def count_requests(request: Request, call_next):
response = await call_next(request)
# Skip scraping so /metrics does not count itself, and label by the
# matched route template, not the raw path, to bound cardinality.
if request.url.path != "/metrics":
route = request.scope.get("route")
path = getattr(route, "path", request.url.path)
requests.labels(request.method, path, response.status_code).inc()
return response
@app.get("/metrics")
def metrics() -> Response:
return Response(generate_latest(registry), media_type=CONTENT_TYPE_LATEST)
@app.get("/items/{item_id}")
def read_item(item_id: int) -> dict[str, int]:
return {"item_id": item_id}
return app
app = create_app()
import httpx
import pytest
from prometheus_client import CollectorRegistry, CONTENT_TYPE_LATEST
from prometheus_client.parser import text_string_to_metric_families
from app import create_app
def make_client(app):
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
)
def sample_value(text, name, **labels):
for family in text_string_to_metric_families(text):
for sample in family.samples:
if sample.name == name and sample.labels == labels:
return sample.value
return None
async def test_scrape_returns_prometheus_text_format():
app = create_app()
async with make_client(app) as client:
response = await client.get("/metrics")
assert response.status_code == 200
assert response.headers["content-type"] == CONTENT_TYPE_LATEST
assert "# HELP http_requests" in response.text
async def test_counter_uses_route_template_not_raw_path():
app = create_app()
async with make_client(app) as client:
await client.get("/items/1")
await client.get("/items/2")
text = (await client.get("/metrics")).text
assert sample_value(
text, "http_requests_total", method="GET", path="/items/{item_id}", status="200"
) == 2
assert sample_value(
text, "http_requests_total", method="GET", path="/items/1", status="200"
) is None
async def test_metrics_scrape_does_not_count_itself():
app = create_app()
async with make_client(app) as client:
await client.get("/metrics")
text = (await client.get("/metrics")).text
assert sample_value(
text, "http_requests_total", method="GET", path="/metrics", status="200"
) is None
async def test_unmatched_route_falls_back_to_raw_path():
app = create_app()
async with make_client(app) as client:
await client.get("/nope")
text = (await client.get("/metrics")).text
assert sample_value(
text, "http_requests_total", method="GET", path="/nope", status="404"
) == 1
def test_registry_rejects_duplicate_metric_names():
registry = CollectorRegistry()
create_app(registry)
with pytest.raises(ValueError, match="Duplicated timeseries"):
create_app(registry)
async def test_separate_apps_keep_separate_registries():
first, second = create_app(), create_app()
async with make_client(first) as client:
await client.get("/items/1")
async with make_client(second) as client:
text = (await client.get("/metrics")).text
assert sample_value(
text, "http_requests_total", method="GET", path="/items/{item_id}", status="200"
) is None
[pytest] pythonpath = . asyncio_mode = auto
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI dependency_overrides tested offline with httpx ASGITransport
Override FastAPI dependencies (including nested ones) and drive the app in-process with httpx.ASGITransport, no network and no running server.
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI endpoints rate limited with slowapi, tested with TestClient
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.
All FastAPI recipesAll prometheus-client recipesAll httpx recipesAll pytest recipes