Python Metrics and Instrumentation: Architecture and Implementation Guide

Metrics are the cheapest, highest-leverage observability signal for Python backends: a handful of counters and histograms tell you request rate, error rate, and latency distribution at a fraction of the storage cost of traces or logs. This guide is for backend engineers and SREs who need to instrument production Python services correctly, and it links the focused guides that go deeper: instrumenting with the prometheus_client library, recording metrics with the OpenTelemetry metrics SDK, choosing metric types and controlling cardinality, and deciding between OpenTelemetry and Prometheus for Python metrics. Metrics also close the loop with the other two signals: they pair with distributed tracing in Python through exemplars, and with structured logging fundamentals through shared trace_id correlation.

The two production metric paths from a Python service A Python service holds counters, gauges and histograms in process. On the pull path it exposes a slash-metrics exposition endpoint that a Prometheus server scrapes every fifteen seconds. On the push path an OpenTelemetry metric reader exports OTLP periodically to a collector that batches and filters. Both paths converge on the same time series store, where series count multiplied by sample rate is the cost. pull path · the monitoring system reaches in Python service counters · gauges histograms in process /metrics endpoint exposition text, serialized OTel metric reader periodic OTLP export Prometheus scrapes every 15s OTel Collector batches, trims, forwards time series store series × samples = cost scrape OTLP push path · the process owns the export cadence
The two production metric paths: a Prometheus-scraped exposition endpoint (pull) and an OpenTelemetry reader that pushes OTLP to a collector — different transports, one store, one bill.

Key architectural decisions to make before you write the first instrument:

  • Transport model: choose pull (Prometheus scrape) or push (OTLP export) per service, deliberately, and make the choice consistent across a deployment tier so dashboards and alerting rules do not have to special-case it.
  • Aggregation boundary: treat metrics as aggregates, not events — a metric is a pre-aggregated number per time series, never one data point per request — and decide explicitly where per-process values are combined into a service total.
  • Cardinality budget: cap the label value sets per metric before shipping it; cardinality, not request volume, is what breaks a metrics backend.
  • Histogram bucket layout: pick boundaries from your SLO thresholds rather than accepting library defaults, because quantiles are interpolated inside a bucket and buckets multiply your series count.
  • Process topology: decide up front how a prefork worker pool reports — shared memory-mapped files under PROMETHEUS_MULTIPROC_DIR, or one independent exporter per worker tagged with an instance identifier.
  • Resource identity: define service.name, service.version, and deployment.environment once and share that definition with traces and logs, because cross-signal correlation is only possible when all three agree on who produced the data.

Foundational Architecture and Metric Standards

A metric is a numeric measurement identified by a name and a set of key/value labels, sampled over time. The combination of metric name plus a unique set of label values defines a single time series. This is the unit your backend stores, indexes, and queries, and it is the unit that determines cost. Understanding that one metric expands into many time series — one per label combination — is the single most important mental model for instrumentation.

There are two dominant collection models in the Python ecosystem. The Prometheus pull model has each process expose a plain-text /metrics endpoint; a Prometheus server scrapes that endpoint on a fixed interval (commonly every 15 seconds) and stores the result. The application is passive — it only maintains current values in an in-process registry. The OpenTelemetry push model runs a metrics SDK inside the process that periodically reads instrument values and exports them over OTLP to a collector or backend. The application is active — it owns the export cadence.

The pull model gives the monitoring system control over scrape timing, makes liveness obvious (a failed scrape is a signal), and needs no per-app egress configuration. The push model fits short-lived jobs, serverless functions, and environments where the app cannot be reached by a scraper, and it unifies metrics with the same OTLP pipeline used for traces and logs. The detailed trade-offs are covered in OpenTelemetry vs Prometheus for Python metrics.

Both models share the same instrument families, and picking the wrong one is expensive to undo once dashboards and alerts depend on it:

Instrument Semantics Typical Python use Aggregates across instances?
Counter Monotonic, only increases or resets to zero on restart Requests served, errors raised, bytes written, tasks completed Yes — sum the rates
Gauge / UpDownCounter Arbitrary current value, up or down In-flight requests, queue depth, connection pool size, resident memory Yes, but only with an explicit combination rule (sum, max, average)
Histogram Observations counted into predefined buckets Request latency, payload size, batch size Yes — buckets are counters, so quantiles are computed after summing
Summary (Prometheus only) Quantiles computed inside the process at observation time Legacy latency metrics on a single instance No — per-process quantiles cannot be averaged
Observable (async) instrument Callback sampled at collection time Values you read rather than increment: pool size, cache entries, loop lag Same as the underlying kind

Choosing correctly between these is consequential enough that it has its own guide on picking counter, gauge, histogram, and summary.

The Prometheus exposition format is the wire contract for the pull model. It is line-oriented UTF-8 text with # HELP and # TYPE comments followed by metric_name{label="value"} number samples. Because it is just text over HTTP, anything that can serve a response can expose metrics, which is why the format became a de facto standard well beyond Prometheus itself. OpenMetrics is its standardized successor and adds exemplars and explicit unit metadata; prometheus_client can serve either, negotiated by the scraper's Accept header.

Naming is part of the contract, not cosmetics. Prometheus convention is snake_case with a unit suffix and a _total suffix on counters — http_request_duration_seconds, http_requests_total, process_resident_memory_bytes — always in base units (seconds, not milliseconds; bytes, not megabytes), because dashboards and recording rules assume it. OpenTelemetry semantic conventions use dotted names and a separate unit field — http.server.request.duration with unit="s" — and the Prometheus exporter mechanically translates dots to underscores and appends the unit. Adopting the semantic conventions rather than inventing names means shared dashboards, alerts, and backend features work on your service without translation.

What you instrument matters as much as how. For request-driven services, the RED method (rate, errors, duration) gives complete coverage with three instruments: a request counter labeled by outcome, an error counter or an error-status label on the same counter, and a latency histogram. For resources — pools, queues, caches, workers — the USE method (utilization, saturation, errors) maps naturally onto gauges plus a counter. Start with those, then add domain metrics that describe business outcomes (orders placed, payments declined) because those are the ones on-call engineers actually alert on.

One instrument declaration becomes many stored time series A single histogram named http_request_duration_seconds carries three label dimensions: method with five values, a templated route with forty values, and a status class with six values. Their cartesian product is one thousand two hundred stored time series, drawn as a grid of cells. The same declaration then multiplies again: twelve histogram buckets take it to fourteen thousand four hundred series, and thirty replicas take it to four hundred and thirty-two thousand. one declaration label dimensions stored time series http_request_ duration_seconds one metric name method 5 values route template 40 values status class 6 values 5 × 40 × 6 combinations 1,200 series one per combination the same declaration, multiplied again: label combinations 1,200 × 12 histogram buckets 14,400 × 30 replicas 432,000
The unit you store and pay for is the series, not the metric: one declaration with three bounded labels is already 1,200 series, and buckets and replicas multiply it again.

Instrumentation Strategy and SDK Configuration

The prometheus_client library is the canonical Python implementation of the pull model. You declare instruments once at module scope, mutate them inside request handlers, and expose the default registry over HTTP. Because instruments live in a global REGISTRY by default, declaring the same metric name twice raises a duplicate-timeseries error — a deliberate guard against accidental double registration. That guard also bites in test suites and in modules imported twice under different names, which is why long-lived codebases usually centralize declarations in one metrics.py module and import the instrument objects everywhere else. The companion guide on instrumenting with prometheus_client walks through registry management and the framework integrations, including adding Prometheus metrics to Flask and exposing custom application metrics.

The OpenTelemetry path centers on a MeterProvider configured with one or more metric readers. The PeriodicExportingMetricReader collects instrument values on an interval and hands them to an exporter such as OTLPMetricExporter. You acquire a Meter from the provider and create instruments — create_counter, create_histogram, create_up_down_counter, create_observable_gauge — from that meter. Observable (asynchronous) instruments take a callback that the reader invokes at collection time, which is the right pattern for values you sample rather than increment, like resident memory or pool size. The mechanics are detailed in the OpenTelemetry metrics SDK guide, with focused walkthroughs on exporting OTLP metrics to the collector and recording counters and histograms.

Provider lifecycle follows the same rules as the tracing SDK: build it once, as early in process startup as possible but after any fork, and shut it down deliberately. MeterProvider.shutdown() performs a final collection and export, which is the difference between having and not having the last ten seconds of data before a deploy or a crash — exactly the window an incident review needs. Register it in an atexit hook, a SIGTERM handler, or an ASGI lifespan shutdown. For batch jobs the final flush is not a nicety but the entire point: a job that finishes in eight seconds with a ten-second export interval reports nothing at all without it.

Configuration should be environment-driven so the same image runs in every tier without a rebuild. The OpenTelemetry SDK reads OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_PROTOCOL, and OTEL_METRIC_EXPORT_INTERVAL directly, so most deployments need no exporter arguments in code at all. On the Prometheus side the equivalent knobs are the scrape port, the bind address, and PROMETHEUS_MULTIPROC_DIR. Keep endpoints and credentials in environment variables, keep bucket boundaries and instrument names in code — the former change per environment, the latter must stay identical across environments or your dashboards will not compare.

Views are the OpenTelemetry SDK's configuration layer for instruments you do not own. A view matches instruments by name, kind, or meter and then rewrites them: rename the output metric, drop specific attribute keys, replace the default histogram bucket boundaries, or discard the instrument entirely. This is how you tame a noisy third-party library without forking it, and it is the push-model equivalent of Prometheus metric_relabel_configs.

Whichever SDK you choose, resource attributes such as service.name and deployment.environment must be attached so that series from different deployments stay distinguishable. In Prometheus these typically arrive as scrape-target labels or relabel rules; in OpenTelemetry they are set on the Resource passed to the MeterProvider, identically to how the tracing guide sets resources on the TracerProvider. Sharing one resource definition across signals is what makes cross-signal correlation work.

The prometheus_client registry path beside the OpenTelemetry MeterProvider graph On the left, instruments declared once at import register into the global CollectorRegistry; on scrape, generate_latest renders the registry into exposition text served on a metrics port. On the right, a Resource identifies the service, a Meter creates the instruments, Views rewrite the resulting streams by renaming them, dropping attributes or replacing bucket boundaries, and a PeriodicExportingMetricReader collects and exports them over OTLP, with shutdown performing the final flush. prometheus_client · pull OpenTelemetry SDK · push Counter · Histogram declared once at import time register in REGISTRY one global CollectorRegistry on scrape generate_latest() renders exposition text HTTP response GET /metrics no lifecycle to shut down Resource service.name · environment stamped on every export Meter → instruments sync calls · observable callbacks stream rewritten by View rename · drop attrs · set buckets collected every 10s Reader → OTLP exporter shutdown() = final flush
Same instruments, two object graphs: the registry path renders on demand and has no lifecycle, while the MeterProvider owns a resource, views, a reader and a shutdown flush you must call.

Label and Cardinality Discipline

Labels are the most powerful and most dangerous feature of dimensional metrics. A label like method or status_code has a small, fixed set of values and produces a manageable number of series. A label like user_id, session_id, a raw URL path with embedded IDs, or a full exception message is unbounded: it grows without limit as traffic flows, and each new value permanently allocates another time series. This is the number-one cause of Prometheus out-of-memory incidents.

The discipline is simple to state and easy to violate: every label must draw from a small, enumerable set known at design time. Normalize before labeling. Replace /orders/84321 with a route template /orders/{id}. Bucket a continuous quantity into ranges rather than labeling the raw value. Strip user-supplied strings entirely. The dedicated guide on controlling label cardinality in Prometheus covers route normalization, allow-lists, and how to find offending series before they cost you an outage.

A practical ceiling: estimate the cartesian product of all label values per metric before shipping it. A metric with method (5) × status (6) × endpoint (40) is 1,200 series — fine. The same metric with endpoint replaced by raw path is unbounded — a latent incident. Multiply that product by the number of replicas, because each instance contributes its own copy of every series, and a 30-pod deployment turns 1,200 into 36,000. When in doubt, drop the label; you can always add a dimension later, but you cannot cheaply reclaim the memory a bad one has already cost.

Cardinality also decays badly over time. A label whose values are bounded but churning — a deployment ID, a pod name, a version string — stays small at any instant while accumulating dead series in the backend's index, which is why long retention windows amplify a label that looked harmless in staging. Prefer attaching such identity to the scrape target or resource, where the backend can treat it as metadata, over baking it into every application-level series.

Two questions catch almost every bad label before it ships. First: can I write down the complete list of values this label will ever take? If not, it is unbounded. Second: will anyone ever group by or alert on this dimension? If not, it is dead weight even when bounded — put it in a structured log line or a span attribute instead, where high-cardinality context belongs and costs far less.

Bounded labels stay flat; one unbounded label climbs without limit Two lines plotted against traffic over time. The bounded series, built from method, status class and a route template, stays flat at about twelve hundred active series no matter how much traffic arrives. The unbounded series, produced by labelling with a raw URL path or a user identifier, rises continuously because every new label value allocates another time series permanently, and it crosses the backend memory ceiling drawn as a dashed line near the top of the plot. active series backend memory ceiling one new series per raw path · never reclaimed bounded label set · flat under any load traffic over time → distinct label values seen bounded method · status class · route template 1,200 series, forever unbounded user_id · session_id · raw URL · exception text grows with traffic churning values (pod name, deploy id) look small now and pile up in the index
Cardinality, not request volume, is the failure mode: a bounded label set is flat at any traffic level, while a single unbounded label allocates a permanent series per value it ever sees.

Histogram Bucket Design for Latency SLOs

A histogram is only as useful as its bucket boundaries. prometheus_client ships default buckets tuned for sub-second web latency (.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10 seconds), but defaults rarely match a specific SLO. If your SLO is "99% of requests under 300 ms," you need a bucket boundary at or very near 0.3, because Prometheus computes quantiles by linear interpolation within a bucket — a boundary far from your target produces a quantile estimate that is wrong by exactly the bucket width.

Design buckets around the thresholds you actually report on. Place explicit boundaries at your SLO targets (for example 0.1, 0.3, 0.5, 1, 2, 5) and add resolution where your traffic concentrates. Logarithmically spaced buckets give good coverage across orders of magnitude when latency varies widely. Remember that every bucket is a separate time series multiplied by every label combination, so a 12-bucket histogram with 1,200 label combinations is over 14,000 series for one metric — bucket count is a cardinality lever too.

There is a second, subtler reason to place a boundary exactly at the SLO threshold: it makes the error budget query exact rather than estimated. Dividing the bucket counter at le="0.3" by the total count gives the true fraction of requests under 300 ms with no interpolation at all, which is the correct way to compute an SLO compliance ratio. Quantile queries are for exploration; bucket ratios are for alerting.

The reason histograms beat summaries for SLOs is aggregation. Histogram buckets are counters, and counters from many instances can be summed by the backend before computing a quantile, giving you a correct fleet-wide p99. Summary quantiles are computed per process and cannot be averaged into a meaningful global quantile. For any latency metric you intend to alert on across a fleet, use a histogram.

One caution when changing boundaries: a histogram's bucket layout is part of its identity. Editing the boundary list on a live metric makes the new series incompatible with the old ones, so quantile queries spanning the deploy return nonsense. Either accept a gap, or ship the new layout under a new metric name and retire the old one after the retention window rolls over.

Default buckets straddle the SLO threshold; aligned buckets land on it The same latency distribution counted into two bucket layouts. With the prometheus_client defaults the three hundred millisecond target falls in the middle of the wide bucket spanning 0.25 to 0.5 seconds, so any quantile near the target is interpolated inside that bucket and is wrong by roughly the bucket width. With boundaries chosen from the SLO the value 0.3 is itself a boundary, so dividing the le equals 0.3 bucket counter by the total count gives the exact fraction of requests under the target with no interpolation. default buckets SLO-aligned buckets observations observations SLO 300 ms .05 .1 .25 .5 1 2.5 0.3 falls inside the 0.25 – 0.5 bucket the quantile is interpolated: off by the bucket width SLO 300 ms .1 .2 .3 .5 1 2.5 a boundary sits exactly on the target bucket at le=0.3 ÷ total count = the exact ratio
Boundaries are not cosmetic: put one on every threshold you alert on, and the SLO ratio becomes a division of two counters instead of an interpolation guess.

Async and Concurrency Patterns

Recording a metric is one of the few observability operations cheap enough to do anywhere. Both prometheus_client and the OpenTelemetry SDK guard instrument state with short-lived locks, so counter.inc() and histogram.observe() are thread-safe and cost microseconds. There is no network call, no serialization, and nothing to await — which means you should never wrap a metric call in a thread executor or an async helper. Call it inline, including from coroutines.

The event loop hazards live around the instruments, not in them. Three patterns cause real incidents. First, observable-instrument callbacks: the OpenTelemetry reader invokes them synchronously on its own collection thread, and they must be plain functions that return immediately. A callback that queries Redis, opens a database cursor, or blocks on a lock stalls the entire export cycle and, if it contends with the loop's own resources, drags request latency with it. Sample the value on a background asyncio task or a timer and let the callback return the last cached number. Second, /metrics handlers that compute rather than serialize (covered below). Third, gauges updated without finally: an in-flight-requests gauge incremented at the start of a coroutine and decremented at the end leaks permanently on cancellation, and asyncio cancels tasks routinely on client disconnects and timeouts. Use a context manager or try/finally so CancelledError still decrements.

The event loop itself deserves instrumentation. Loop lag — the delay between when a callback was scheduled and when it ran — is the single best indicator that some coroutine is blocking, and it is trivial to measure: schedule a task that sleeps a fixed interval, compare the actual elapsed time to the intended interval, and record the difference in a gauge or histogram. Complement it with the number of pending tasks and the executor queue depth. When latency rises and loop lag rises with it, the problem is blocking code in the loop, not a slow dependency — a distinction that would otherwise take a long profiling session to reach.

Thread and process boundaries behave differently for metrics than for traces. Instruments are global objects, so a ThreadPoolExecutor worker incrementing a counter needs no context handoff at all — this is where metrics are strictly easier than tracing, which requires copying context across the boundary as described in async tracing patterns. What does need context is exemplars: attaching the active trace_id to an observation reads the current OpenTelemetry context via contextvars, so an exemplar recorded on a bare executor thread will be empty unless the context was propagated. Record the observation on the coroutine that owns the span, not on the worker thread.

The GIL rarely limits metric recording, because the critical section is a few bytecodes long. It does matter for the collection path: serializing tens of thousands of series into exposition text is CPU work that holds the GIL, so a very large registry scraped every five seconds can measurably steal time from request handling. If a scrape takes hundreds of milliseconds, the fix is fewer series, not a faster serializer.

Where metrics touch the event loop, and where they stall it On the asyncio event loop, recording an observation is a microsecond tick that needs no await, while a blocking call in the same coroutine shows up as loop lag. On the OpenTelemetry collection thread, an observable callback that queries a database stalls the whole export cycle, whereas a callback that returns a cached value finishes in microseconds. A background sampling task running every few seconds refreshes that cached value off the collection path. asyncio event loop blocking call → loop lag counter.inc() and histogram.observe(): microseconds, inline, nothing to await OpenTelemetry collection thread callback queries the database ✗ stalls the whole export cycle callback returns a cached value ✓ returns in microseconds background sampling task asyncio task, 5s refreshes the cache sample the expensive values here: queue depth, pool size, loop lag feeds A gauge raised on entry must be lowered in a finally block — CancelledError skips everything after the await.
Recording is free; the hazards sit around it — a callback that does I/O on the collection thread, and a gauge that never comes back down when a task is cancelled.

Multiprocess Collection Under Gunicorn and Uvicorn

The pull model has a sharp edge with WSGI/ASGI worker pools. Under gunicorn (or uvicorn with multiple workers), each worker is a separate OS process with its own in-memory registry. A single scrape of one shared port reaches exactly one randomly chosen worker, so counters appear to bounce around and reset — you are sampling one worker's view, not the service total.

prometheus_client solves this with multiprocess mode. You set the PROMETHEUS_MULTIPROC_DIR environment variable to a writable directory; each worker writes its metric state to memory-mapped files there. The scrape endpoint then builds a fresh CollectorRegistry, attaches a MultiProcessCollector pointed at that directory, and aggregates across all workers at scrape time. Counters and histograms sum correctly; gauges support modes like livesum, max, and liveall to control how per-process values combine.

Multiprocess mode has operational requirements that are easy to miss. The directory must be empty at startup — stale files from a previous run are read as if those processes still existed, inflating counters after every restart — so clear it in the entrypoint. It should live on a tmpfs or emptyDir volume, not on durable storage, because every increment touches a memory-mapped page. Worker death must be reported via multiprocess.mark_process_dead(worker.pid) from gunicorn's child_exit hook, or a recycled worker's series linger. And a few features are unavailable in this mode: custom collectors that compute at scrape time do not participate, and the default process and platform collectors report per-process values that need care when aggregated.

The OpenTelemetry push model sidesteps this differently: each worker process runs its own MeterProvider and exports independently over OTLP, tagging exports with a process or instance identifier. The backend aggregates across instances. There is no shared-file dance, but you must ensure the SDK is initialized after the worker forks, because exporter background threads and HTTP connections do not survive fork(). Initialize in a gunicorn post_fork hook or an ASGI lifespan startup, never at import time before the master forks. The same rule applies to Celery workers with the prefork pool, where the worker_process_init signal is the correct hook.

Container schedulers add one more consideration: with the push model every replica is a distinct series producer, so an autoscaling deployment that churns pods produces a stream of short-lived instance identifiers. Aggregate away the instance dimension in recording rules or collector processors when you only ever query the service total.

Reporting from a prefork worker pool: shared files versus independent exporters On the left, a gunicorn master forks four workers that each write metric state into memory-mapped files under PROMETHEUS_MULTIPROC_DIR on a tmpfs volume; at scrape time a fresh registry with a MultiProcessCollector merges those files into one aggregated exposition, with counters summed and gauges combined by mode. On the right, each worker initialises its own MeterProvider after the fork in a post_fork hook and exports OTLP independently, so the collector and backend aggregate across instance identifiers instead. prometheus_client · shared files OpenTelemetry · one exporter each gunicorn master · -w 4 worker 1 mmap worker 2 mmap worker 3 mmap worker 4 mmap PROMETHEUS_MULTIPROC_DIR mmap files · tmpfs, emptied at start MultiProcessCollector fresh registry on every scrape one aggregated exposition counters summed · gauges by mode gunicorn master · post_fork hook worker 1 provider worker 2 provider worker 3 provider worker 4 provider OTLP collector four independent export streams backend aggregates sums across instance ids no shared files at all but churning pods churn instance ids
A single scrape reaches one random worker, so a prefork pool has to aggregate somewhere: in shared memory-mapped files at scrape time, or in the backend across per-worker OTLP streams.

Network and Protocol Integration

For the pull model, the contract is a single HTTP GET /metrics returning exposition text with a Content-Type of text/plain; version=0.0.4, or the OpenMetrics content type when the scraper negotiates it via Accept — which is required for exemplars to travel at all. prometheus_client provides start_http_server(port) for a standalone thread, make_wsgi_app() to mount inside an existing WSGI app, make_asgi_app() for ASGI frameworks, and generate_latest(registry) to render the body yourself for custom routes. Mounting inside your app reuses its port and TLS; a separate port isolates metrics from request traffic, keeps them off your public listener, and lets you firewall it independently. In Kubernetes the separate port is usually the better default, because a ServiceMonitor or scrape annotation can target a named port that no ingress ever exposes.

Keep the endpoint cheap. It should read current registry state and serialize — never trigger database queries or recompute expensive values inline. For values that are expensive to sample (queue depth from an external system, for instance), refresh them on a background timer or via an observable instrument and let the endpoint serve the cached number. A /metrics handler that blocks on I/O will time out scrapes under load and create gaps exactly when you most need data. Set the scrape timeout below the scrape interval so a slow endpoint degrades into missing samples rather than overlapping scrapes.

For the push model the network surface is OTLP. The SDK speaks gRPC on port 4317 or HTTP/protobuf on port 4318, selected by OTEL_EXPORTER_OTLP_PROTOCOL or by importing the matching exporter class. gRPC keeps one multiplexed connection open and has the lowest per-export cost; HTTP/protobuf traverses proxies, service meshes, and egress rules that only understand HTTP. Authentication travels as headers — OTEL_EXPORTER_OTLP_HEADERS carries API keys for managed backends — and TLS is negotiated by the endpoint scheme. Compression (gzip) is worth enabling on any link leaving the node, since exposition-shaped payloads compress extremely well.

Where the two worlds meet, three bridges matter. The Prometheus exporter in the OpenTelemetry SDK turns a MeterProvider into a scrapeable endpoint, letting you instrument with the vendor-neutral API while the platform team keeps pulling. The collector's Prometheus receiver does the reverse: it scrapes your existing exposition endpoints and forwards them as OTLP, which is how a fleet migrates without touching application code. And remote-write ships Prometheus data onward to long-term storage. Choosing among these is a topology decision, covered end to end in OpenTelemetry vs Prometheus for Python metrics.

Short-lived processes are the one case pull genuinely cannot serve. A batch job or cron task may finish before any scrape fires, so either push a final OTLP export on shutdown or write its result to a Pushgateway that Prometheus scrapes on the job's behalf. Do not reach for a Pushgateway for long-lived services — it becomes a stale-metric graveyard, since it keeps serving the last value forever after the pusher disappears.

The two network surfaces, and the bridges between them A single pod runs one Python process. On one side it exposes a named metrics port serving slash metrics, which a Prometheus server reaches by service discovery every fifteen seconds; that port is never published through an ingress. On the other side an OTLP exporter opens an outbound connection to a collector over gRPC on port 4317 or HTTP protobuf on port 4318. Two bridges connect the models: the OpenTelemetry Prometheus exporter makes an OTel-instrumented app scrapeable, and the collector's Prometheus receiver turns existing exposition endpoints into OTLP. one pod · one process Python app instruments :9100 /metrics OTLP out 4317 / 4318 Prometheus service discovery · 15s OTel Collector processors · batch · drop pull: named port, no ingress push: gzip, headers, TLS bridges between the two worlds OTel Prometheus exporter instrument with OTel, stay scrapeable collector prometheus receiver scrape what exists → forward as OTLP Short-lived jobs: flush OTLP on exit, or push to a Pushgateway that Prometheus scrapes. Remote-write ships either path onward to long-term storage.
Two network surfaces — an inbound scrape on a named port and an outbound OTLP connection — plus the two bridges that let a fleet move from one to the other without rewriting instrumentation.

Data Volume Control and Cost Management

Metrics cost is dominated by active series count, not request rate, because storage and memory scale with the number of distinct time series a backend must hold in its head block. The levers are therefore all about series count: fewer labels, bounded label values, fewer histogram buckets, and dropping series you never query. Audit your metrics periodically and delete instruments and labels nothing alerts or dashboards on — unused series are pure cost.

On the pull side, control cost with scrape interval and metric_relabel_configs that drop noisy series at ingestion. Raising an interval from 15s to 30s halves sample volume without changing series count, which helps storage but not memory — series count is the memory lever, sample rate is the disk lever, and knowing which one you are constrained by decides which knob to turn. Recording rules pre-compute expensive aggregations so dashboards query small derived series instead of scanning raw ones.

On the push side, the OpenTelemetry SDK supports views that rename instruments, drop attributes, or change histogram bucket boundaries before export, plus delta vs cumulative temporality choices that affect backend storage. Delta temporality sends only the change since the last export, which keeps payloads small and suits backends that sum on ingest; cumulative sends running totals, which is what Prometheus expects and what survives a dropped export without losing data. The collector adds a second filtering tier — processors can drop metrics, trim attributes, and batch — and it is the right place to enforce policy, because changing collector config does not require redeploying every service.

Exemplars add a small, bounded cost (a trace_id attached to a sampled bucket) and are worth it for the trace correlation they unlock. The same trace_id injected into your structured logs lets a latency spike jump straight to the offending logs and traces — one dashboard click from "p99 doubled at 14:03" to the exact request that caused it, without a text search.

What each cost lever actually removes Starting from a baseline of four hundred and thirty-two thousand active series — twelve hundred label combinations times twelve histogram buckets times thirty replicas — the bars show what remains after each lever. Dropping series nothing queries with relabel rules removes about forty percent. Halving the bucket count halves the total. Dropping one six-valued label divides it by six. Aggregating the instance dimension away in recording rules divides it by thirty. A note below records that raising the scrape interval halves sample volume but leaves series count, and therefore memory, unchanged. active series left after each lever · same traffic, same code paths baseline: 1,200 × 12 buckets × 30 pods 432,000 drop series nothing queries (relabel) 259,200 12 histogram buckets → 6 216,000 drop the status label (6 values) 72,000 aggregate the instance away 14,400 raising the scrape interval 15s → 30s halves samples, not series sample rate is the disk lever · series count is the memory lever
Every lever here removes series except the last note: knowing whether you are constrained by memory (series) or disk (samples) decides which knob is worth turning.

Production Code Examples

Prometheus Instrumentation with a Latency Histogram

Declares instruments once at module scope, records request count and latency with bounded labels, and exposes them on a dedicated metrics port.

# pip install "prometheus-client>=0.20.0,<1.0.0"
import time
from prometheus_client import Counter, Histogram, start_http_server

# Bounded labels only: method and a normalized route template, plus status class.
REQUESTS = Counter(
    "http_requests_total",
    "Total HTTP requests.",
    ["method", "route", "status"],
)
LATENCY = Histogram(
    "http_request_duration_seconds",
    "Request latency in seconds.",
    ["method", "route"],
    # Buckets placed around a 300ms p99 SLO target.
    buckets=(0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.5),
)


def handle(method: str, route: str) -> int:
    start = time.perf_counter()
    status = "200"
    try:
        time.sleep(0.12)  # simulated work
        return 200
    finally:
        LATENCY.labels(method, route).observe(time.perf_counter() - start)
        REQUESTS.labels(method, route, status).inc()


if __name__ == "__main__":
    start_http_server(9100)  # serves GET /metrics on :9100
    handle("GET", "/orders/{id}")
    handle("GET", "/orders/{id}")

Expected Output: A scrape of http://localhost:9100/metrics returns exposition text:

# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/orders/{id}",status="200"} 2.0
# HELP http_request_duration_seconds Request latency in seconds.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.2",method="GET",route="/orders/{id}"} 0.0
http_request_duration_seconds_bucket{le="0.3",method="GET",route="/orders/{id}"} 2.0
http_request_duration_seconds_bucket{le="+Inf",method="GET",route="/orders/{id}"} 2.0
http_request_duration_seconds_count{method="GET",route="/orders/{id}"} 2.0
http_request_duration_seconds_sum{method="GET",route="/orders/{id}"} 0.24...

OpenTelemetry Metrics SDK with OTLP Export

Builds a MeterProvider with a periodic reader and OTLP exporter, then records a counter and a histogram from a meter.

# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"
import os
import time
from opentelemetry import metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

resource = Resource.create({
    "service.name": os.getenv("OTEL_SERVICE_NAME", "order-service"),
    "deployment.environment": os.getenv("DEPLOY_ENV", "production"),
})

# Reader collects every 10s and pushes over OTLP. Initialize AFTER any fork.
reader = PeriodicExportingMetricReader(
    OTLPMetricExporter(endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317")),
    export_interval_millis=10_000,
)
provider = MeterProvider(resource=resource, metric_readers=[reader])
metrics.set_meter_provider(provider)

meter = metrics.get_meter("order-service")
requests = meter.create_counter("http.server.requests", unit="1")
latency = meter.create_histogram("http.server.duration", unit="s")


def handle(route: str) -> None:
    start = time.perf_counter()
    time.sleep(0.12)
    attrs = {"http.route": route, "http.status_code": 200}
    latency.record(time.perf_counter() - start, attrs)
    requests.add(1, attrs)


handle("/orders/{id}")
provider.shutdown()  # final collection + export; never exit without it

Expected Output: No console output; every 10 seconds — and once more on shutdown — the reader exports an OTLP payload. A debug collector logs a representative metric:

{
  "name": "http.server.duration",
  "unit": "s",
  "histogram": {
    "dataPoints": [
      {
        "attributes": {"http.route": "/orders/{id}", "http.status_code": 200},
        "count": 1,
        "sum": 0.121,
        "bucketCounts": [0, 0, 1, 0]
      }
    ],
    "aggregationTemporality": "CUMULATIVE"
  }
}

Multiprocess Aggregation Under Gunicorn

Aggregates per-worker metric files into one registry at scrape time so a multi-worker deployment reports correct service-wide totals.

# pip install "prometheus-client>=0.20.0,<1.0.0" "gunicorn>=21.2.0,<23.0.0"
# Run with: PROMETHEUS_MULTIPROC_DIR=/tmp/prom gunicorn -w 4 app:app
import os
from prometheus_client import CollectorRegistry, generate_latest, CONTENT_TYPE_LATEST
from prometheus_client import multiprocess


def metrics_app(environ, start_response):
    # Build a fresh registry per scrape and aggregate all worker files.
    registry = CollectorRegistry()
    multiprocess.MultiProcessCollector(registry)  # reads PROMETHEUS_MULTIPROC_DIR
    data = generate_latest(registry)
    start_response("200 OK", [("Content-Type", CONTENT_TYPE_LATEST)])
    return [data]


def child_exit(server, worker):
    # Required so a dead worker's series are cleaned up.
    multiprocess.mark_process_dead(worker.pid)


def on_starting(server):
    # Stale files from a previous run would inflate counters after a restart.
    path = os.environ["PROMETHEUS_MULTIPROC_DIR"]
    os.makedirs(path, exist_ok=True)
    for name in os.listdir(path):
        os.remove(os.path.join(path, name))

Expected Output: A scrape returns counters summed across all four workers rather than one worker's partial count, e.g. http_requests_total{...} 812.0 instead of a number that resets each scrape.

Common Mistakes

  • Putting unbounded values in labels: Labeling with user_id, raw URLs, request IDs, or exception messages creates an unbounded number of time series and is the most common cause of Prometheus running out of memory. Normalize to bounded templates before labeling.
  • Running multiple gunicorn/uvicorn workers without multiprocess mode: Without PROMETHEUS_MULTIPROC_DIR and MultiProcessCollector, each scrape hits one random worker, so counters look like they reset and totals are silently wrong.
  • Using a summary when you need fleet-wide quantiles: Summary quantiles are computed per process and cannot be aggregated, so your "p99" is really one instance's p99. Use a histogram so the backend can compute a correct global quantile.
  • Default histogram buckets that miss the SLO threshold: Quantiles are interpolated within a bucket, so if no boundary sits near your SLO target the p99 estimate is off by the bucket width. Place explicit boundaries at the thresholds you alert on.
  • Initializing the OTel MeterProvider before the worker forks: Exporter threads and connections do not survive fork(), so a provider built at import time silently stops exporting in forked workers. Initialize in a post_fork hook or lifespan startup.
  • Doing expensive work in the /metrics handler or an observable callback: Querying a database or recomputing values inline blocks scrapes and stalls the export cycle, creating data gaps under load. Sample expensive values on a background task and serve the cached number.
  • Exiting without a final flush: A batch job or a pod receiving SIGTERM drops whatever the reader has not yet exported, losing exactly the window before a crash or deploy. Call MeterProvider.shutdown() from a termination hook.
  • Leaving a gauge incremented on cancellation: An in-flight counter raised at the start of a coroutine and lowered at the end leaks on CancelledError, so the gauge drifts upward forever. Wrap it in try/finally or a context manager.

Taken together, these decisions compose into one pipeline: bounded instruments declared once, buckets aligned to the thresholds you alert on, a process model that aggregates worker state correctly, a transport chosen to match how your workloads live and die, and a filtering tier that keeps series count — the thing you actually pay for — under control. Each guide in this section drills into one stage of that pipeline, and they are designed to fit together into a low-overhead metrics layer that correlates cleanly with your traces and logs.

Frequently Asked Questions

Should I use prometheus_client or the OpenTelemetry metrics SDK for a new Python service?

If your platform already runs Prometheus and you want the simplest path, prometheus_client and a scraped /metrics endpoint is the least friction. If you want a single vendor-neutral pipeline shared with traces and logs, use the OpenTelemetry metrics SDK with OTLP export. Both can coexist because the OTel SDK ships a Prometheus exporter.

Why are my Prometheus metrics empty or wrong under gunicorn with multiple workers?

Each gunicorn worker is a separate process with its own in-memory registry, so a scrape only ever hits one random worker. Set PROMETHEUS_MULTIPROC_DIR and use prometheus_client.multiprocess.MultiProcessCollector so counts and histograms are aggregated across all workers.

How many label combinations is too many for a single metric?

Each unique combination of label values creates a separate time series stored independently. A few thousand series per metric is usually fine; tens or hundreds of thousands from unbounded labels like user_id, raw URLs, or request IDs will overwhelm Prometheus memory. Keep label values to a small, bounded set.

What is the difference between a histogram and a summary in prometheus_client?

A histogram counts observations into predefined buckets and lets Prometheus compute quantiles across many instances server-side, which is what you want for SLOs. A summary computes quantiles locally in the process and cannot be aggregated across instances. Prefer histograms for latency.

How do I correlate a metric spike with a specific trace?

Use exemplars: prometheus_client and the OpenTelemetry SDK can attach a trace_id to a sampled histogram observation. Your backend then links a point on the latency graph directly to the trace that produced it, joining the metrics and tracing signals.

Does recording metrics block the asyncio event loop?

Recording an observation is an in-memory operation guarded by a short lock, so it is safe on the event loop and costs microseconds. What blocks the loop is doing I/O in the path: an observable-instrument callback that queries a database, or a /metrics handler that recomputes values inline. Keep sampling on a background task and let the endpoint serve cached numbers.

Should I export OTLP over gRPC or HTTP/protobuf?

gRPC on port 4317 is the default and gives multiplexed, long-lived connections with the lowest per-export overhead. HTTP/protobuf on port 4318 is easier to route through proxies, load balancers, and corporate egress rules that only understand HTTP. Both carry identical payloads, so pick whichever your network path handles cleanly.