Exposing Custom Metrics with the Prometheus Client

Framework auto-instrumentation gives you HTTP counters, but the numbers that matter to the business — orders placed, payments declined, queue depth, batch rows processed — only you can emit. This page is for backend engineers and SREs who already have a /metrics endpoint and now need to add their own signals to it with prometheus_client, naming and labeling them so they stay queryable and cheap. It builds on the broader Prometheus client instrumentation guide and is part of the Python Metrics and Instrumentation guide.

The whole discipline reduces to three decisions per metric: which instrument type, what name and unit, and which labels. Get those right and the series are cheap, aggregatable, and self-documenting; get the labels wrong and you take down your Prometheus server.

The three decisions behind every custom metric A business signal is routed by its shape: only ever increases to a Counter named orders_processed_total, rises and falls to a Gauge named order_queue_depth, a distribution to a Histogram named payment_gateway_duration_seconds. All three instruments then meet the same labeling decision, where bounded values such as outcome, payment method and region become labels while high-cardinality identifiers such as user id and order id are sent to logs and traces instead. business signal Counter only ever increases orders_processed_total Gauge rises and falls order_queue_depth Histogram a distribution payment_gateway_duration_seconds attach labels every value combination is one series bounded values become labels outcome · payment_method · region identifiers never become labels user id · order id → logs and traces
Three decisions per metric: the instrument its shape demands, the name and unit it carries, and the labels it is allowed to keep.

Prerequisites

Only the client is required to define and render metrics; you expose them through whatever server you already run.

pip install "prometheus-client>=0.20.0,<1.0.0"

If you serve metrics from a prefork server (gunicorn, multi-worker uvicorn), set the multiprocess directory before the workers start so custom series aggregate across workers exactly like default ones.

export PROMETHEUS_MULTIPROC_DIR=/tmp/app_prom
mkdir -p "$PROMETHEUS_MULTIPROC_DIR"

Nothing else is needed: there is no exporter to configure and no background thread to start. Custom metrics live in the same process memory as the default collectors and are serialized only when a scrape arrives.

Implementation

Step 1 — Map each signal to an instrument type. A monotonic total uses a Counter. A value that rises and falls uses a Gauge. A distribution of magnitudes or durations uses a Histogram. The decision is rarely ambiguous once you ask "does this only ever go up?" — and the deeper trade-offs, especially Histogram versus Summary and why per-process quantiles cannot be aggregated, are covered in choosing between Counter, Gauge, Histogram, and Summary.

Step 2 — Declare the instruments once, in one module. Constructing an instrument registers it with the default REGISTRY, and registering the same name twice raises Duplicated timeseries in CollectorRegistry. Put every instrument at module level in a single module that business code imports; never construct instruments inside a request handler, a factory function, or a class body that runs more than once.

# business_metrics.py — one module owns the instruments
from prometheus_client import Counter, Gauge, Histogram

# Counter: orders only ever accumulate
ORDERS = Counter(
    "orders_processed_total",
    "Orders processed, by outcome and payment method",
    ["outcome", "payment_method"],          # both are small enumerations
)

# Gauge: queue depth goes up and down
QUEUE_DEPTH = Gauge(
    "order_queue_depth",
    "Orders currently waiting in the processing queue",
)

# Histogram: payment latency distribution, buckets in seconds
PAYMENT_LATENCY = Histogram(
    "payment_gateway_duration_seconds",
    "Payment gateway round-trip latency in seconds",
    ["payment_method"],
    buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)

Buckets are the one histogram argument worth deliberating over. They are cumulative upper bounds in seconds, they are fixed at construction, and every bucket multiplies the series count for that metric — eight buckets plus +Inf, _sum, and _count is eleven series per label combination. Choose boundaries that straddle your SLO threshold so a quantile query has resolution where it matters, rather than the default set tuned for generic sub-second web requests.

Step 3 — Record from business code. Increment counters on outcomes, set or shift gauges as state changes, and time histograms around the operation. The .time() context manager observes elapsed seconds on exit; .count_exceptions() increments only when the block raises; .track_inprogress() on a gauge increments on entry and decrements on exit, which is the correct way to measure concurrency.

from business_metrics import ORDERS, QUEUE_DEPTH, PAYMENT_LATENCY

def process_order(order, method):
    QUEUE_DEPTH.dec()                                   # leaving the queue
    with PAYMENT_LATENCY.labels(method).time():         # observe round trip
        ok = charge(order, method)
    outcome = "success" if ok else "declined"
    ORDERS.labels(outcome=outcome, payment_method=method).inc()

.labels(...) performs a dictionary lookup and returns a child object. In a hot loop, resolve the child once outside the loop and call .inc() on it, rather than re-resolving per iteration.

Step 4 — Read externally owned values at scrape time. For values your code does not naturally push on every change — a queue depth held by Redis, a cache size, a licence-seat count — use a Gauge callback so the value is read when Prometheus scrapes. This avoids drift between the real value and a stale reported one.

QUEUE_DEPTH.set_function(lambda: redis.llen("order_queue"))  # read at scrape

The callback runs inline during exposition, so it must be fast and must not raise: a slow call delays every scrape, and an exception fails the whole endpoint, not just that metric. Wrap anything that touches the network in a timeout and fall back to the last known value. When one source yields several related metrics at once, write a custom collector class with a collect() method instead of stacking callbacks.

Where a gauge value is read from: written on change, or read at scrape time The dashed line is the real queue depth in Redis over time. In the upper timeline the gauge is written only when application code happens to call set, so the value Prometheus records is a staircase that holds flat between writes; at the third scrape it reports seven while the real depth is fourteen. In the lower timeline the gauge carries a set_function callback, so at each scrape marker the sample is taken from the source and lands exactly on the real value. real value in Redis value Prometheus records Gauge.set() from business code the value only moves when your code writes it 16 0 queue depth drift at this scrape: reports 7, really 14 Gauge.set_function(callback) the callback runs inside the scrape, so the sample is current 16 0 queue depth callback reads 8 reads 14 at scrape time scrape scrape scrape the callback runs inline: a slow read delays every scrape, and a raise fails the whole endpoint
A pushed gauge reports whatever was last written; a set_function gauge reads the source inside the scrape, so every sample is current.

Step 5 — Follow naming and unit conventions. Names are the contract every dashboard and alert depends on. Use snake_case, base units, and the conventional suffixes: _total for counters, _seconds for any duration, _bytes for sizes. Prefix related metrics with the subsystem they belong to so they group in autocomplete. Never bake units into the value (no milliseconds, no kilobytes) — store seconds and bytes and let PromQL scale for display. The # TYPE line is emitted automatically from the instrument class, so you do not write it, and the help string is the only documentation most on-call engineers will ever read: make it a sentence, not a repeat of the name.

Signal Good name Why
Orders processed orders_processed_total _total marks a counter
Payment latency payment_gateway_duration_seconds base unit seconds, _seconds suffix
Queue depth order_queue_depth gauge, no _total suffix
Cache size cache_resident_bytes base unit bytes, _bytes suffix
Batch rows written etl_rows_written_total subsystem prefix groups the family

These conventions are Prometheus-specific. If the same service also emits OpenTelemetry instruments, note that OTel uses dotted names and a separate unit field, and the translation between the two is one of the practical differences discussed in OpenTelemetry vs Prometheus for Python metrics.

Step 6 — Keep labels bounded. Every distinct combination of label values is a separate time series stored in memory and on the wire. Labels must draw from small, enumerable sets — outcome, payment method, region, HTTP status class. Never label by user ID, email, order ID, raw URL, or any free-form string, because each new value adds a permanent series and the total multiplies across labels. A counter with outcome (3 values) and payment_method (4 values) is 12 series; adding user_id makes it unbounded. The full treatment, including how to estimate and cap the series budget, is in controlling label cardinality in Prometheus.

# WRONG: user_id is unbounded -> one series per user, forever
ORDERS.labels(outcome="success", payment_method="card", user_id=order.user).inc()

# RIGHT: bounded labels only; identity belongs in logs or traces
ORDERS.labels(outcome="success", payment_method="card").inc()

The identity you just removed is not lost — it belongs in a log line or a span attribute, where storage is per-event rather than per-series. Emitting the order ID through structured logging with the Python standard library, and correlating with metrics by adding trace IDs to log records, gives you the drill-down without the cardinality bill.

Twelve cells, or no ceiling at all On the left, the labels outcome with three values and payment_method with four values form a lattice of twelve cells, and each cell is exactly one stored time series. On the right, adding a user_id label multiplies those twelve series by every distinct user identifier seen, so the stored series count climbs from about one hundred and twenty at ten users to roughly one hundred and twenty thousand at ten thousand users, with no upper bound. Identity belongs in a log line or a span attribute, which is stored per event rather than per series. bounded labels · enumerable values card wallet transfer invoice success declined error 3 outcomes × 4 methods = 12 series one stored time series per cell, forever flat one high-cardinality label 12 bounded series × every distinct user id no ceiling up to 120 000 stored series 10 100 1k 5k 10k distinct user_id values seen the identity you removed belongs in a log line or a span attribute — stored per event, not per series
Every cell of the label lattice is one stored series; a single unbounded label turns that fixed grid into a line with no ceiling.

Step 7 — Pre-seed label children so series exist before the first event. A labeled metric does not emit a series for a given label combination until that combination is first observed. This means a dashboard panel for orders_processed_total{outcome="declined"} shows "no data" until the first decline happens, and an alert on rate(...) == 0 never fires because the expression returns an empty vector rather than zero. Pre-create the children you know about by calling .labels(...) once at startup; for a counter this registers a series at value 0.

# Pre-seed known label combinations so the series exist from boot
for outcome in ("success", "declined"):
    for method in ("card", "wallet", "transfer"):
        ORDERS.labels(outcome=outcome, payment_method=method)

This only helps for label sets you can enumerate — which is exactly the bounded labels you should be using. If you cannot enumerate the values to pre-seed, that is a strong signal the label is too high-cardinality and belongs in a log line, not a metric.

Step 8 — Expose the instruments. Custom metrics registered with the default REGISTRY render automatically through the same endpoint as everything else — make_wsgi_app(), make_asgi_app(), start_http_server(), or a manual generate_latest() route. There is no extra registration step; constructing the instrument is the registration. The only requirement is that the module holding the instruments is imported by the time the first scrape lands, which for a start_http_server() process means importing it explicitly.

from prometheus_client import start_http_server
import business_metrics  # importing it registers the instruments

start_http_server(8000)   # custom series now appear at /metrics

Inside a web framework the instruments ride the endpoint you already mounted — the wiring for that, including the WSGI dispatcher and the gunicorn child_exit hook, is covered in instrumenting Flask with Prometheus metrics.

Configuration Options

Option Applies to Purpose Notes
labelnames=[...] all instruments Declares label keys Keep value sets small and enumerable
_total / _seconds / _bytes suffix naming PromQL/Grafana convention Base units only; never ms or KB
Histogram(buckets=...) histogram Distribution boundaries Tune around your SLO threshold
Gauge.set_function(fn) gauge Read value at scrape time Must be fast and must not raise
Counter.count_exceptions() counter Increment only on raise Wraps a block as context manager
Gauge.track_inprogress() gauge Concurrency measurement Increments on enter, decrements on exit
multiprocess_mode gauge Cross-worker reduction livesum, max, min, all under prefork
registry= all Isolate from default Use a fresh CollectorRegistry() in tests
Anatomy of one Histogram declaration and the lines it renders Five numbered parts of a Histogram construction map to the scrape output. The documentation string becomes the HELP comment. The Histogram class itself sets the TYPE comment to histogram, which you never write. The metric name prefixes every sample line. The labelnames become the brace-delimited label set on each sample. The buckets tuple produces one underscore bucket line per boundary plus a plus-infinity line, and with the underscore sum and underscore count lines that is eleven series for every label combination. one Histogram declaration what it renders at /metrics 1 documentation "Payment gateway round-trip latency" 2 the class you called Histogram(...) — not Summary 3 name "payment_gateway_duration_seconds" 4 labelnames ["payment_method"] 5 buckets (0.05, 0.1, 0.25, … 5.0, 10.0) 1 # HELP payment_gateway_duration_seconds Payment gateway round-trip latency 2 # TYPE payment_gateway_duration_seconds histogram — set for you, never written by you 3 payment_gateway_duration_seconds_bucket 4 {payment_method="card", le="0.05"} 12 {payment_method="card", le="0.1"} 15 5 one line per boundary, then le="+Inf" payment_gateway_duration_seconds_sum payment_gateway_duration_seconds_count 8 boundaries + Inf + _sum + _count = 11 series per label combination
Nothing in the exposition is written by hand: each line is produced by one argument of the declaration, and the bucket list decides how many series it costs.

Verification

Drive a few operations, then scrape and confirm the suffixes, types, and bounded labels.

curl -s localhost:8000/metrics | grep -E "orders_processed|payment_gateway|order_queue"

Expected Output:

# HELP orders_processed_total Orders processed, by outcome and payment method
# TYPE orders_processed_total counter
orders_processed_total{outcome="success",payment_method="card"} 18.0
orders_processed_total{outcome="declined",payment_method="card"} 2.0
# HELP order_queue_depth Orders currently waiting in the processing queue
# TYPE order_queue_depth gauge
order_queue_depth 4.0
# HELP payment_gateway_duration_seconds Payment gateway round-trip latency in seconds
# TYPE payment_gateway_duration_seconds histogram
payment_gateway_duration_seconds_bucket{payment_method="card",le="0.5"} 15.0
payment_gateway_duration_seconds_bucket{payment_method="card",le="1.0"} 20.0
payment_gateway_duration_seconds_bucket{payment_method="card",le="+Inf"} 20.0
payment_gateway_duration_seconds_sum{payment_method="card"} 7.84
payment_gateway_duration_seconds_count{payment_method="card"} 20.0

Three things to check in that output. The # TYPE lines must match the instrument you intended — a gauge where you expected a counter usually means the wrong class or a missing _total. The histogram must carry _bucket, _sum, and _count; if only _sum and _count appear you are looking at a Summary. And the series count must be small and stable:

curl -s localhost:8000/metrics | grep -c "^orders_processed_total{"

A healthy scrape shows a handful of series per metric — one per bounded label combination — not thousands. If that count grows with traffic or user count, a label is unbounded and must be removed.

For a regression test, assert on the registry directly rather than parsing text. get_sample_value reads a single sample by name and exact label set, so it fails loudly if you rename a metric or change its labels.

from prometheus_client import REGISTRY

LABELS = {"outcome": "declined", "payment_method": "card"}

def test_declined_order_increments_counter():
    before = REGISTRY.get_sample_value("orders_processed_total", LABELS) or 0.0
    process_order(make_order(), "card")          # gateway stubbed to decline
    after = REGISTRY.get_sample_value("orders_processed_total", LABELS)
    assert after == before + 1                    # reads the delta, not the total

Comparing a delta rather than an absolute value keeps the test order-independent, which matters because the default registry is process-global and other tests will have incremented the same counter.

Three things to read in the scrape output A single scrape of the metrics endpoint feeds three independent checks. First, the TYPE comment must match the instrument you intended; if it does not, the wrong class was used or a counter is missing its underscore total suffix. Second, a histogram must expose underscore bucket, underscore sum and underscore count; if only sum and count appear, the metric is a Summary. Third, the series count per metric must stay flat as traffic grows; if it grows with users or paths, a label is unbounded. When all three pass, the series is correctly typed, complete and bounded. what to check what a failure means scrape output curl /metrics # TYPE matches the instrument counter · gauge · histogram _bucket, _sum and _count all three families present series count stays flat one per bounded label set fails fails fails wrong instrument class or a counter missing _total only _sum and _count that is a Summary, not a Histogram a label is unbounded the count grows with traffic all three pass → the series is correctly typed, complete and bounded
Read the scrape output as three checks; each failure points at exactly one cause, and none of them need a dashboard to spot.

Common Mistakes

Units encoded in the name or value

Error signature: PromQL math is off by 1000; a panel labeled "seconds" shows milliseconds. Root cause: the metric stores milliseconds or kilobytes and the name lies, or the name carries a unit the value contradicts. Remediation: always store base units — seconds, bytes — and suffix the name accordingly (_seconds, _bytes). Let Grafana and PromQL scale for display rather than pre-scaling in the application.

A label value set that grows without bound

Error signature: Prometheus memory climbs steadily, prometheus_tsdb_head_series grows linearly with traffic, and queries slow down. Root cause: a label carries a high-cardinality value such as user ID, order ID, or raw path, creating a new permanent series per value. Remediation: remove the offending label and move that identity into logs or trace attributes. Keep labels to small enumerations and validate the series budget per the label cardinality guidance.

Duplicated timeseries on import or reload

Error signature: ValueError: Duplicated timeseries in CollectorRegistry: {'orders_processed_total'} at startup, in tests, or on an autoreload. Root cause: the same metric name was constructed twice against the default registry — instruments defined inside an app factory, a class that is instantiated per request, or a module imported under two paths. Remediation: define instruments once at module level and import that module everywhere you record. In tests, pass registry=CollectorRegistry() so each test builds an isolated registry instead of colliding with the global one.

Custom series disappear or fluctuate under gunicorn

Error signature: counters jump backwards between scrapes and totals look far too small for the real traffic. Root cause: each prefork worker keeps its own registry, so a scrape reports whichever worker answered. Remediation: set PROMETHEUS_MULTIPROC_DIR before workers start, render the scrape through a MultiProcessCollector, give every gauge an explicit multiprocess_mode, and call mark_process_dead in the child_exit hook. The full wiring lives in the Prometheus client instrumentation guide.

Which layer each custom-metric failure comes from A panel off by a factor of one thousand comes from unit choice: store seconds and bytes and suffix the name accordingly. Head series growing forever comes from label design: keep label values bounded and move identity to logs. A Duplicated timeseries error comes from the registration lifecycle: declare each instrument once at module level. Counters jumping backwards come from the process model: set the multiprocess directory and render through a MultiProcessCollector. what you see where it comes from a panel is off by 1000 a seconds axis showing milliseconds unit choice store base units, suffix the name head series grow forever memory climbs with traffic label design bounded values, identity to logs Duplicated timeseries raised at import, test or reload registration lifecycle declare once at module level counters jump backwards totals far too small for the traffic process model multiproc dir + MultiProcessCollector
Each symptom belongs to exactly one layer — unit, label, registration, or process model — which is what makes these failures quick to place.

Frequently Asked Questions

What suffix should a custom metric name use?

Suffix monotonic counters with _total and any duration with _seconds, using base units throughout. A latency histogram is request_duration_seconds and a processed-items counter is items_processed_total. The TYPE comment is set automatically by the instrument class.

Can I use a high-cardinality value like user ID as a label?

No. Each unique label value is a separate stored time series, so user IDs, request paths, and email addresses cause unbounded series growth that overloads Prometheus. Keep labels to small enumerable sets and push high-cardinality identifiers into logs or traces instead.

How do I track a value the application does not push, like a queue depth?

Use a Gauge with a callback through the set_function method, or a custom collector, so the value is read at scrape time rather than maintained on every change. This avoids drift between the real value and the reported one.

Why do I get a Duplicated timeseries in CollectorRegistry error?

The same metric name was constructed twice against the same registry, usually because instruments are created inside a function, a class, or a module that is imported under two different names. Declare each instrument once at module level and import that module wherever you record, or pass an explicit registry in tests.

Do custom metrics work under gunicorn with several workers?

Only if you enable multiprocess mode. Set PROMETHEUS_MULTIPROC_DIR before the workers start and render the scrape through a MultiProcessCollector, otherwise each worker keeps a private registry and a scrape reports whichever worker answered. Gauges additionally need a multiprocess_mode such as livesum or max to say how workers should be combined.