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.

Two apps behind one port, sharing one registry A single WSGI entrypoint, DispatcherMiddleware, receives both kinds of traffic on the same bind. Ordinary paths go to the Flask app, where before_request starts a timer and increments the in-flight gauge, the view runs, and after_request observes latency and increments the counter; teardown_request always runs as well. Those hooks write into one CollectorRegistry per process, holding the Counter, Histogram and Gauge defined once in metrics.py. A scrape of slash metrics is routed instead to make_wsgi_app, which reads that same registry and serialises it as exposition text without passing through the hooks, so scrapes never count themselves. client traffic GET /users/1 Prometheus GET /metrics DispatcherMiddleware one bind, one WSGI app any other path the /metrics prefix Flask app — hooks cover every request before_request start timer in-flight +1 view your code after_request observe latency count + status and teardown_request always runs one registry per process Counter · Histogram · Gauge, built once in metrics.py read on each scrape make_wsgi_app() serialises the registry — bypasses the hooks one port, one bind, one set of access controls — the scrape comes in through Flask's own front door
The hooks write to a single registry; the exposition app mounted beside Flask reads it. Same port, same bind — and because the scrape never reaches the hooks, it never counts itself.

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.

Which hook runs in each case, and what it records Three request outcomes across the four lifecycle hooks. For a matched route returning 200, before_request starts the timer and increments the in-flight gauge, the view runs, after_request observes the latency and counts status 200, and teardown_request decrements the gauge. For an unmatched 404, before_request still runs, no view is ever called because Flask answers the 404 itself, after_request still runs with request.url_rule set to None so the endpoint label must fall back to a single unmatched value, and teardown_request decrements the gauge. When the view raises, before_request runs, the view raises with no response, after_request is skipped entirely because there is no response to return, and teardown_request runs — receiving the exception, counting status 500 and decrementing the gauge. Only teardown_request runs in all three cases. the same four hooks, three different endings before_request view after_request teardown_request 200 OK a matched route runs timer starts in-flight +1 runs returns a response runs observe latency count status 200 runs in-flight −1 unmatched 404 url_rule is None runs timer starts in-flight +1 never called Flask answers 404 runs endpoint <unmatched> guard, or it raises runs in-flight −1 view raises exception escapes runs timer starts in-flight +1 raises no response exists skipped nothing to observe the request vanishes runs count status 500 in-flight −1 solid = the hook runs · dashed = it does not — only teardown_request runs in all three, which is why it owns the gauge
Read the bottom row first: after_request never sees a request that raised, so without a teardown_request hook those failures leave no counter increment and a gauge that never comes back down.

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.

What the scrape looks like when the endpoint label is wrong Two panels showing exposition text for identical traffic. On the left the endpoint label is taken from request.path, so each concrete URL — slash users slash one, slash users slash two, slash users slash ninety-three, slash users slash eight thousand one hundred and twenty-three — mints its own permanent series, one more for every id ever requested, and every histogram multiplies that count by its buckets plus sum and count. On the right the label is taken from request.url_rule.rule, so all of that traffic folds into a single series keyed by the route pattern slash users slash int colon user underscore id, with unmatched paths folding into one unmatched series. the same requests to /users/1 and /users/2, two label choices endpoint = request.path one permanent series per concrete URL flask_http_requests_total{endpoint="/users/1"} 1.0 flask_http_requests_total{endpoint="/users/2"} 1.0 flask_http_requests_total{endpoint="/users/93"} 1.0 flask_http_requests_total{endpoint="/wp-admin"} 1.0 …and one more for every id — or every path a bot invents the histogram then multiplies each by its buckets series count: unbounded endpoint = request.url_rule.rule the route pattern, never the value in it …_total{endpoint="/users/<int:user_id>"} 7.0 …_total{endpoint="<unmatched>"} 1.0 every id folds into the one pattern above every unknown path folds into the fallback growth is bounded by your route table… …which is a number you can actually budget series count: one per route the grep above is the test — two different ids must produce one line, not two
Two ids, one line. The left panel is what a single missing 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.

Why a scrape under gunicorn needs PROMETHEUS_MULTIPROC_DIR On the left, four prefork workers each keep a private in-process registry. A single GET of slash metrics is answered by whichever worker the operating system happens to hand the connection to, so the other three never contribute, totals jump between scrapes and every number looks far too small for the real request rate. On the right, with PROMETHEUS_MULTIPROC_DIR set, all four workers write memory-mapped counter and histogram files into that directory, one set per process id. A MultiProcessCollector on a fresh CollectorRegistry merges those files on every scrape, producing one consistent exposition whose totals only ever grow, while the child_exit hook calls mark_process_dead so a dead worker's files stop contributing. without PROMETHEUS_MULTIPROC_DIR worker 1 own counts worker 2 own counts worker 3 own counts worker 4 own counts GET /metrics one worker answers whichever one the OS hands the socket to totals jump between scrapes and every number reads a quarter too small with PROMETHEUS_MULTIPROC_DIR worker 1 mmap file worker 2 mmap file worker 3 mmap file worker 4 mmap file PROMETHEUS_MULTIPROC_DIR counter_*.db and histogram_*.db, one set per pid MultiProcessCollector on a fresh registry rebuilt and merged on every scrape one exposition, totals only grow and child_exit retires a dead worker's files counters and histograms sum across workers on their own — a Gauge still needs an explicit multiprocess_mode
The left panel is what "metrics differ on every scrape" actually looks like: four private registries and a load balancer's coin flip deciding which one the scrape sees.

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/, not the concrete path. Labeling by the raw request.path creates one series per id value and blows up cardinality, so guard for None on unmatched requests and fold them into a single fallback label.

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.