Instrumenting Flask with Prometheus Metrics
You need request counts and latency histograms out of a Flask app, exposed on /metrics, and correct when gunicorn runs several workers. This walkthrough is for backend engineers and SREs who already run Flask in production and want first-party instrumentation without a third-party exporter in the dependency tree. It builds on the broader Prometheus client instrumentation guide and is part of the Python Metrics and Instrumentation guide.
The core idea is small: define instruments once, record them in before_request/after_request so you do not touch every view, and mount the client's WSGI exposition app alongside Flask so scraping uses the same port. Everything else on this page is about the two things that go wrong afterwards — unbounded endpoint labels, and prefork workers each answering the scrape with their own private numbers.
Prerequisites
Pin the client and the WSGI server. Flask itself is WSGI, so the client's make_wsgi_app() mounts directly with no adapter and no ASGI bridge.
pip install "prometheus-client>=0.20.0,<1.0.0" \
"flask>=3.0.0,<4.0.0" \
"gunicorn>=21.2.0,<23.0.0"
For multi-worker deployments, export the multiprocess directory before gunicorn starts. It must exist, be writable by the worker user, and be cleared on each boot so stale worker files do not resurrect old series after a redeploy.
export PROMETHEUS_MULTIPROC_DIR=/tmp/flask_prom
mkdir -p "$PROMETHEUS_MULTIPROC_DIR" && rm -f "$PROMETHEUS_MULTIPROC_DIR"/*
Nothing else is required: no agent, no sidecar, no push step. If you have not yet decided whether scraping is the right transport for this service at all, settle that first with OpenTelemetry vs Prometheus for Python metrics and come back once pull is the answer.
Implementation
Step 1 — Define instruments in their own module. Constructing a metric twice raises ValueError: Duplicated timeseries, so declare the request counter, the latency histogram, and an in-flight gauge once and import the objects everywhere else. Choose the endpoint label from the route rule rather than the raw path, and tune histogram buckets to web latency in seconds. The reasoning behind picking a Histogram here rather than a Summary is covered in choosing between Counter, Gauge, Histogram, and Summary.
# metrics.py — the single owner of every instrument in the process
from prometheus_client import Counter, Gauge, Histogram
REQUEST_COUNT = Counter(
"flask_http_requests_total",
"Total Flask HTTP requests",
["method", "endpoint", "status"],
)
REQUEST_LATENCY = Histogram(
"flask_http_request_duration_seconds",
"Flask request latency in seconds",
["method", "endpoint"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
)
IN_PROGRESS = Gauge(
"flask_http_requests_in_progress",
"Requests currently being handled",
["method"],
multiprocess_mode="livesum", # sum live workers, ignore dead ones
)
Three naming rules keep these queryable: _total on the counter, _seconds on the duration, and no unit baked into the value. Store seconds and let PromQL scale for display.
Step 2 — Record latency with before/after hooks. before_request stamps a monotonic start time onto Flask's request-scoped g object; after_request reads it back, observes the elapsed seconds, and increments the counter. Using request.url_rule.rule keeps the endpoint label bounded to your route patterns instead of one series per concrete URL — the practice detailed in controlling label cardinality in Prometheus.
# app.py
import time
from flask import Flask, g, request, Response
from metrics import REQUEST_COUNT, REQUEST_LATENCY, IN_PROGRESS
app = Flask(__name__)
def _endpoint() -> str:
# request.url_rule is None for 404s; fold them into one series
return request.url_rule.rule if request.url_rule else "<unmatched>"
@app.before_request
def _start_timer():
g._start = time.perf_counter() # request-scoped start time
IN_PROGRESS.labels(request.method).inc()
@app.after_request
def _record(response: Response):
endpoint = _endpoint()
elapsed = time.perf_counter() - getattr(g, "_start", time.perf_counter())
REQUEST_LATENCY.labels(request.method, endpoint).observe(elapsed)
REQUEST_COUNT.labels(request.method, endpoint, response.status_code).inc()
return response
@app.route("/users/<int:user_id>")
def get_user(user_id: int):
return {"id": user_id}
The 404 fallback matters more than it looks. An unmatched route has request.url_rule is None, so a scanner hitting random paths would otherwise crash the hook on every probe. Folding all unmatched requests into one <unmatched> series both fixes the crash and caps cardinality — without it, a bot sweep quietly mints one permanent series per invented URL.
Step 3 — Close the exception path. after_request does not run when an unhandled exception escapes the view, and it is exactly those requests you most want counted. teardown_request always runs, receives the exception (or None), and is the right place to record failures and release the in-flight gauge so it cannot drift upward across a deploy.
@app.teardown_request
def _finish(exc):
IN_PROGRESS.labels(request.method).dec() # always release the gauge
if exc is not None: # an exception escaped the view
REQUEST_COUNT.labels(request.method, _endpoint(), 500).inc()
Why hooks instead of a decorator. A decorator on each view would miss requests Flask handles itself — 404s, 405s, error handlers, static files — and would force you to remember to wrap every new route. The before_request/after_request/teardown_request trio runs for the whole request lifecycle regardless of which view fires, so coverage is complete by construction and new routes are instrumented the moment they are registered. In an application-factory layout, register the hooks inside create_app() but keep the instruments in metrics.py at module scope: the factory may run more than once in tests, and the instruments must not.
Step 4 — Mount the metrics endpoint on the same port. Wrap the Flask WSGI app with DispatcherMiddleware so /metrics is served by the client's exposition app while everything else routes to Flask. This avoids opening a second port and inherits Flask's bind address, TLS termination, and any front-proxy auth. Mounting it as middleware rather than a Flask route also keeps the exposition out of your own after_request hook, so scrapes do not count themselves.
# wsgi.py — the gunicorn entrypoint
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from prometheus_client import make_wsgi_app
from app import app as flask_app
application = DispatcherMiddleware(flask_app, {
"/metrics": make_wsgi_app(), # client renders the exposition
})
Treat this endpoint as internal. The exposition leaks route names, error rates, and build information, so keep it behind the same network policy or ingress rule that protects your admin surface.
Step 5 — Aggregate across gunicorn workers. With PROMETHEUS_MULTIPROC_DIR set, instruments write to per-worker memory-mapped files instead of process memory, and the scrape must merge them through a MultiProcessCollector. Build a fresh CollectorRegistry inside a custom metrics app so the default per-process collectors do not double-count, and register the child_exit hook so a dead worker's files stop contributing.
# wsgi.py — multiprocess-aware variant
import os
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from prometheus_client import (
CollectorRegistry, make_wsgi_app, multiprocess,
)
from app import app as flask_app
def metrics_app(environ, start_response):
registry = CollectorRegistry() # fresh per scrape
multiprocess.MultiProcessCollector(registry) # merge per-worker files
return make_wsgi_app(registry)(environ, start_response)
target = metrics_app if os.environ.get("PROMETHEUS_MULTIPROC_DIR") else make_wsgi_app()
application = DispatcherMiddleware(flask_app, {"/metrics": target})
# gunicorn.conf.py
from prometheus_client import multiprocess
def child_exit(server, worker):
multiprocess.mark_process_dead(worker.pid) # clean dead-worker files
Run it:
gunicorn -c gunicorn.conf.py --workers 4 --bind 0.0.0.0:8000 wsgi:application
Counters and histograms sum across workers automatically. Gauges do not — they need an explicit multiprocess_mode, which is why IN_PROGRESS above declares livesum. Also note that the default process collectors (process_resident_memory_bytes and friends) are not exported in multiprocess mode, because a single figure across four workers would be meaningless; scrape those from the container runtime instead.
Configuration options
| Option | Where | Purpose | Notes |
|---|---|---|---|
PROMETHEUS_MULTIPROC_DIR |
env | Per-worker metric files | Required for gunicorn aggregation; clear on boot |
request.url_rule.rule |
hook | endpoint label value |
Route pattern, not raw path; bounds cardinality |
make_wsgi_app(registry) |
mount | Renders exposition | Mount via DispatcherMiddleware on /metrics |
Histogram(buckets=...) |
instrument | Latency buckets in seconds | Tune to your SLO thresholds, keep the count small |
child_exit hook |
gunicorn | Cleans dead-worker files | Calls mark_process_dead(worker.pid) |
multiprocess_mode |
Gauge only | Cross-worker reduction | Use livesum for in-flight gauges |
Verification
Drive a few requests to /users/1 and /users/2, then scrape and confirm two things: the counter is labeled by route pattern rather than concrete ID, and the histogram exposes the bucket, sum, and count series.
curl -s localhost:8000/metrics | grep flask_http
Expected Output:
# HELP flask_http_requests_total Total Flask HTTP requests
# TYPE flask_http_requests_total counter
flask_http_requests_total{endpoint="/users/<int:user_id>",method="GET",status="200"} 7.0
# HELP flask_http_request_duration_seconds Flask request latency in seconds
# TYPE flask_http_request_duration_seconds histogram
flask_http_request_duration_seconds_bucket{endpoint="/users/<int:user_id>",le="0.005",method="GET"} 5.0
flask_http_request_duration_seconds_bucket{endpoint="/users/<int:user_id>",le="0.05",method="GET"} 7.0
flask_http_request_duration_seconds_bucket{endpoint="/users/<int:user_id>",le="+Inf",method="GET"} 7.0
flask_http_request_duration_seconds_sum{endpoint="/users/<int:user_id>",method="GET"} 0.041
flask_http_request_duration_seconds_count{endpoint="/users/<int:user_id>",method="GET"} 7.0
The single endpoint="/users/<int:user_id>" series across both IDs is the signal that cardinality is controlled. If you instead see /users/1 and /users/2 as separate series, the hook is labeling by request.path and must be fixed before this ships.
url_rule lookup costs you: a series per URL, permanent for the retention window, multiplied again by every histogram bucket.Under gunicorn, verify the merge as well: ls "$PROMETHEUS_MULTIPROC_DIR" should show one counter_*.db and histogram_*.db pair per live worker, and repeated scrapes should show a total that only ever grows. A total that jumps up and down between scrapes means the merge is not happening.
Lock the behaviour in with a test so a future refactor cannot silently drop it. Flask's test client exercises the same hooks:
# test_metrics.py
from prometheus_client import REGISTRY
from app import app
def test_route_pattern_is_the_label():
client = app.test_client()
client.get("/users/1")
client.get("/users/2")
value = REGISTRY.get_sample_value(
"flask_http_requests_total",
{"method": "GET", "endpoint": "/users/<int:user_id>", "status": "200"},
)
assert value == 2.0 # one series, both requests
Expected Output:
test_metrics.py::test_route_pattern_is_the_label PASSED
Common mistakes
Hook crashes on 404 requests
Error signature: AttributeError: 'NoneType' object has no attribute 'rule', thrown from after_request whenever a scanner hits an unknown path.
Root cause: unmatched requests have request.url_rule is None, so reading .rule raises inside the hook and turns every 404 into a 500.
Remediation: guard with request.url_rule.rule if request.url_rule else "<unmatched>", which both fixes the crash and collapses all unmatched traffic into a single low-cardinality series.
Metrics differ on every scrape under gunicorn
Error signature: totals fluctuate rather than accumulate, and the numbers look far too small for the real request rate.
Root cause: prefork workers each own a private registry and the scrape lands on whichever worker the OS picks.
Remediation: set PROMETHEUS_MULTIPROC_DIR, render the scrape through a MultiProcessCollector on a fresh CollectorRegistry, and add the child_exit hook. Full wiring lives in the Prometheus client instrumentation guide.
Duplicated timeseries when the app factory runs twice
Error signature: ValueError: Duplicated timeseries in CollectorRegistry: {'flask_http_requests_total'} on the second call to create_app(), usually in the test suite or under the reloader.
Root cause: the instruments are constructed inside the factory, so each call re-registers the same metric names with the global registry.
Remediation: keep instrument construction at module scope in metrics.py and import the objects into the factory; register only the hooks per app. In tests that genuinely need isolation, pass a throwaway registry=CollectorRegistry() to each instrument.
Related
- Prometheus client instrumentation in Python — the parent guide: registries, exposition format, and every scrape endpoint style.
- Exposing custom metrics with the Prometheus client — the next step, adding business signals beside these HTTP series.
- Controlling label cardinality in Prometheus — how to budget series and cap free-form label values.
- Choosing between Counter, Gauge, Histogram, and Summary — why request latency belongs in a Histogram.
- Exporting OTLP metrics to the collector — the push-based alternative if scraping the pod is not an option.
Frequently Asked Questions
Do I need prometheus-flask-exporter to instrument Flask?
No. The official prometheus-client gives you everything: define a Counter and Histogram, record them in before_request and after_request hooks, and mount make_wsgi_app on a route. The exporter is a convenience wrapper, not a requirement, and writing the hooks yourself keeps the endpoint label under your control.
Where do I get the route label without exploding cardinality?
Use request.url_rule.rule, which is the route pattern such as /users/
Why are my Flask metrics inconsistent across gunicorn workers?
Each prefork worker keeps its own registry, so a scrape sees one worker. Set PROMETHEUS_MULTIPROC_DIR and render the scrape through a MultiProcessCollector on a fresh CollectorRegistry, and call mark_process_dead in the gunicorn child_exit hook so dead workers stop contributing stale files.