Metrics¶
Prometheus-compatible metrics — request counts, latency histograms, and
any counters/gauges/histograms your own code defines — all exposed at
/metrics in the Prometheus text exposition
format,
scraped directly by Prometheus itself or anything speaking the same
format (Grafana Agent, VictoriaMetrics, Datadog's OpenMetrics ingestion).
No new dependency: the exposition format is a small, stable, documented
text format, and zeython.metrics implements it directly rather than
pulling in the full prometheus_client package for what's fundamentally
a handful of counters this framework already knows how to compute from
the request/response objects it sees.
For distributed tracing (spans across a request, not counters), see Tracing instead.
Setup¶
# main.py
from zeython import Application, MetricsServiceProvider
app = Application()
app.register(MetricsServiceProvider(app))
Registered by default in a generated project — zero-config and safe to always register, the same way Health Check is. The built-in HTTP metrics have no cardinality risk (see below) and add a single dict lookup and a few increments per request.
Configurable via .env:
METRICS_ENABLED— defaulttrue.METRICS_PATH— default/metrics.
What's collected out of the box¶
Every request is instrumented automatically:
http_requests_total{method, path, status}— aCounter.http_request_duration_seconds{method, path}— aHistogram, with Prometheus's own default bucket boundaries.http_requests_in_progress{method}— aGauge.
path is the route's own path template (/posts/{id}), never the
literal URL — grouping by literal URL would mean an ever-growing,
unbounded set of label combinations for anything with a numeric or UUID
path parameter, which is exactly the kind of cardinality explosion that
makes a metrics backend fall over. A request that matched no route at all
(a 404, or a probing bot) is grouped under path="unmatched" for the same
reason. The /metrics endpoint itself is excluded from these counts.
# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/posts/{id}",status="200"} 42
# HELP http_request_duration_seconds HTTP request duration in seconds.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",path="/posts/{id}",le="0.005"} 30
...
http_request_duration_seconds_sum{method="GET",path="/posts/{id}"} 1.284
http_request_duration_seconds_count{method="GET",path="/posts/{id}"} 42
# HELP http_requests_in_progress HTTP requests currently being processed.
# TYPE http_requests_in_progress gauge
http_requests_in_progress{method="GET"} 0
Defining your own metrics¶
Resolve the MetricsRegistry from the container to define a metric
anywhere your own code runs — a controller, a job, a scheduled task:
from zeython.metrics import MetricsRegistry
# Define once, ideally at module or boot level, and reuse the object --
# more efficient than looking it up on every call.
def register_metrics(app):
registry = app.container.make(MetricsRegistry)
return registry.counter("orders_placed_total", "Orders placed.")
ORDERS_PLACED = register_metrics(app)
@app.post("/orders")
async def create_order(request):
...
ORDERS_PLACED.inc()
return JSONResponse(order.to_dict(), status_code=201)
registry.counter()/.gauge()/.histogram() are idempotent by name —
calling one again with the same name returns the same metric object
rather than registering a duplicate, so it's safe to call from inside a
request handler on every request too, not just once at startup.
Counter¶
Only ever goes up — request counts, jobs processed, errors seen:
requests = registry.counter("errors_total", "Errors seen.", labelnames=("kind",))
requests.inc(kind="validation")
Gauge¶
Can go up or down — in-flight requests, queue depth, connections open right now:
Histogram¶
A distribution of observed values, bucketed by upper bound — request durations, payload sizes:
payload_size = registry.histogram("payload_size_bytes", "Request payload size.")
payload_size.observe(len(body))
Pass buckets=(...) to override the default bucket boundaries (seconds,
tuned for HTTP latency) with ones that fit whatever you're measuring.
Restricting access¶
/metrics has no authentication of its own — anyone who can reach it
sees request-rate/latency/error-count data broken down by route, which
can hand reconnaissance (which endpoints exist and are hot, which are
erroring) to an attacker if the path is reachable from the public
internet. The standard fix is the same one Prometheus's own docs
recommend: don't expose it publicly at all — restrict it at your reverse
proxy/load balancer/network policy to the scraper's own IP range, the
same way you'd already restrict access to anything else meant for
infrastructure rather than end users. If you need it reachable from
outside that boundary, wrap the route in your own check before
registering MetricsServiceProvider, or front it with
SecurityHeadersServiceProvider/a custom ASGI middleware that enforces
whatever auth fits your deployment — there's no framework-provided gate
here, the same way there's deliberately none on /up
(Health Check).
Scraping it¶
A minimal Prometheus config:
scrape_configs:
- job_name: my-app
static_configs:
- targets: ["my-app:8000"]
metrics_path: /metrics
API reference¶
See zeython.metrics for the full API.