The OpenTelemetry Metrics SDK in Python

Recording metrics with OpenTelemetry in Python means wiring together a small set of explicit objects: a MeterProvider that owns configuration, one or more Meter instances that mint instruments, the instruments themselves, and a metric reader that periodically collects and exports aggregated data. Unlike ad-hoc counters scattered through a codebase, this SDK gives you a single configuration point for aggregation, temporality, and export, which is what makes it predictable under load. This guide is part of the Python Metrics and Instrumentation guide, and it shares its provider-and-exporter mental model with OpenTelemetry SDK setup for tracing; if you have already configured a TracerProvider, the metrics path will feel familiar. Two focused walkthroughs extend this page: exporting OTLP metrics to the Collector and recording counters and histograms with OpenTelemetry. If you have not yet decided whether this SDK or a scraped exposition endpoint fits your deployment, start with OpenTelemetry vs Prometheus for Python metrics and come back here once the push model is the answer.

The path of one measurement through the metrics SDK Left to right: instruments (Counter, Histogram, UpDownCounter and the observable variants) record into a Meter identified by module name and version; Views rename, drop attributes and pin bucket boundaries; a metric reader collects every fifteen seconds on a background thread under a delta or cumulative temporality; the OTLP exporter sends one gRPC batch to the Collector on port 4317. The middle three stages are owned by the MeterProvider, which is configured once at startup and registered globally. Recording is cheap because add and record only touch in-memory accumulators, while exporting is periodic and happens off the request path. one measurement, left to right MeterProvider — assembled once at startup, registered globally Instruments Counter Histogram Observable* Meter one per module name + version = the scope Views rename or drop attribute keys bucket bounds Metric reader collects every 15 s off-thread delta or cumul. OTLP exporter gRPC to the Collector, 4317 one batch/cycle Nothing leaves the process until a reader is attached: a provider with no reader aggregates in memory and drops it at exit. Recording is cheap add() and record() only touch in-memory accumulators Exporting is periodic the reader thread collects, then pushes one OTLP batch
How measurements travel from instruments through aggregation and periodic collection to the OTLP exporter — and why the recording side stays off the network.

Prerequisites

Isolate a virtual environment and pin the metrics packages. The SDK and the gRPC OTLP exporter share a release train, so pin them to the same range. The API package is a transitive dependency of the SDK but pinning it explicitly keeps the data model fixed.

python -m venv .venv && source .venv/bin/activate
pip install \
  "opentelemetry-api>=1.30.0,<2.0.0" \
  "opentelemetry-sdk>=1.30.0,<2.0.0" \
  "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"

The same pins expressed for a pyproject.toml dependency list, which is what you want in a service repository:

[project]
dependencies = [
  "opentelemetry-api>=1.30.0,<2.0.0",
  "opentelemetry-sdk>=1.30.0,<2.0.0",
  "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0",
]

You also need a reachable OTLP endpoint. For local development, run an OpenTelemetry Collector listening on 4317 for gRPC. The Collector receiver wiring is covered in detail in exporting OTLP metrics to the Collector. Set the environment so the SDK picks up identity and endpoint without code changes between environments:

export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=3.1.0,deployment.environment=production"
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://otel-collector:4317"
export OTEL_METRIC_EXPORT_INTERVAL="15000"     # milliseconds

Constructor arguments always win over these variables, so decide per setting whether it belongs in code (things that are true of the service everywhere, like bucket boundaries) or in the environment (things that differ per deployment, like the endpoint).

Concept & architecture

The metrics SDK is built from five collaborating objects. Understanding each one prevents the most common misconfigurations.

The MeterProvider is the root. It holds the Resource, the set of metric readers, and any View objects. You construct it once at process startup and register it globally with metrics.set_meter_provider(). Its lifecycle matters in two directions: nothing is exported until a reader is attached, and on shutdown you must call shutdown() (or rely on the atexit hook installed by shutdown_on_exit=True) so the final batch flushes. Registration is also one-way — a second call to set_meter_provider() logs a warning and is ignored, which is why bootstrap must run before any module-level instrument creation.

A Meter is obtained from the provider via meter_provider.get_meter(name, version). The name and version form the instrumentation scope that travels with every exported metric. Create one Meter per module or library rather than one global Meter, so data is attributable to the code that produced it; the scope also gives you a clean way to drop or rename all metrics from one library later with a meter_name-selected View.

Instruments are the recording surface. Synchronous instruments are called from your code path; asynchronous instruments are read on demand through callbacks. The full set, with the aggregation each one gets unless a View says otherwise:

Instrument Kind Default aggregation Records Typical use
Counter synchronous monotonic sum add(non-negative) requests served, bytes written, errors
UpDownCounter synchronous non-monotonic sum add(signed) queue depth, in-flight requests
Histogram synchronous explicit-bucket histogram record(value) request duration, payload size
Gauge synchronous last value set(value) a sampled value you already hold in hand
ObservableCounter callback monotonic sum absolute total per collection cumulative CPU seconds, /proc counters
ObservableUpDownCounter callback non-monotonic sum absolute total per collection pool size, cache entries
ObservableGauge callback last value current value per collection memory usage, connection-pool depth

The observable variants are ideal for values you can sample but not increment. Note the subtlety in ObservableCounter: the callback reports the absolute running total, not the change since the last collection, and the SDK computes the delta itself — returning an increment there produces a series that undercounts badly. The mechanics of recording on each instrument are detailed in recording counters and histograms with OpenTelemetry, and the question of which instrument a given measurement deserves is worked through in choosing counter, gauge, histogram, and summary.

A metric reader collects aggregated state from the instruments and hands it to an exporter. The PeriodicExportingMetricReader runs a background timer thread that collects on a fixed interval and pushes through an OTLPMetricExporter. Because collection runs off-thread, recording on synchronous instruments stays cheap and non-blocking, which makes it safe inside asyncio request handlers. Two other readers matter in practice: PrometheusMetricReader, which turns the same instruments into a scrapeable exposition endpoint (the bridge described in OpenTelemetry vs Prometheus for Python metrics), and InMemoryMetricReader, which collects on demand and is what you attach in tests.

A View is an optional transformation applied between an instrument and its aggregation. Views rename metrics, drop or allow specific attribute keys (the single most effective cardinality control), or override the aggregation, most importantly to set explicit histogram bucket boundaries.

What the MeterProvider owns, layer by layer A single MeterProvider carries the Resource and contains two Meters, one per instrumentation scope: a checkout API scope holding a Counter, a Histogram, an UpDownCounter and an ObservableGauge, and a database pool scope holding a Counter, a Histogram, an ObservableUpDownCounter and a Gauge. Measurements from both scopes pass through a shared View layer that can rename a stream, allow-list attribute keys, override the aggregation or drop it entirely. Below the View layer two readers hang off the same provider: a PeriodicExportingMetricReader that keeps delta or cumulative state and pushes OTLP to the Collector every fifteen seconds, and a PrometheusMetricReader that keeps its own always-cumulative state and serves a scraped exposition endpoint. Each reader holds a separate copy of the aggregation state. MeterProvider — the root object Resource: service.name · service.version · deployment.environment Meter — scope checkout.api get_meter(__name__, version) Counter Histogram UpDownCounter ObservableGauge Meter — scope db.pool one Meter per module or library Counter Histogram Gauge ObservableUpDown measurements measurements View layer — selector matched once, at first record() rename · attribute_keys allow-list · aggregation override · DropAggregation collect on interval collect on scrape PeriodicExportingMetricReader its own delta or cumulative state OTLP exporter → Collector push, one batch per interval PrometheusMetricReader its own state, always cumulative /metrics exposition endpoint pull, scraped on demand Each reader keeps its own aggregation state, so one instrument can be delta over OTLP and cumulative on /metrics at once.
One provider, two instrumentation scopes, one View layer — and a separate copy of the aggregation state inside every reader attached to it.

Temporality

Temporality decides what a counter value means at export time. Under cumulative temporality each export carries the running total since the start of the process; under delta temporality each export carries only the change since the previous collection. Cumulative is robust to dropped exports because the next export re-states the total; delta is lighter and suits backends that recompute rates per interval. You set a preference per instrument kind on the exporter, or globally with OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE set to cumulative, delta, or lowmemory — the last being delta for counters and histograms but cumulative for up/down and observable instruments, which is the memory-cheapest combination the specification defines.

The choice is not cosmetic: it changes how the receiving backend computes rates and how it handles process restarts. A Prometheus-style store expects monotonic cumulative series and derives rates by differencing scrapes, so feeding it delta data produces nonsensical counters. A hosted OTLP endpoint that aggregates server-side often prefers delta, because each process restart resets a cumulative counter to zero and forces the backend to detect the reset. Pick one preference, encode it on the exporter, and keep it consistent across every service that writes to the same backend, otherwise dashboards mix two interpretations of the same metric name. The export hop and its temporality flag are covered end to end in exporting OTLP metrics to the Collector.

Temporality also has a memory cost that shows up at high cardinality. Cumulative aggregation must retain one accumulator per attribute set for the life of the process, so a series that appeared once at 03:00 is still held — and still exported — at 18:00. Delta aggregation can release an attribute set after it is exported, which is why the lowmemory preference exists. If a long-running service's resident memory grows in step with the number of distinct attribute combinations it has ever seen, cumulative temporality plus unbounded attributes is the usual culprit, and the fix is a View that bounds the attribute set rather than a temporality change.

Cumulative versus delta over five collections, with one dropped export One counter records 12, 9, 15, 11 and 14 measurements across five fifteen-second collection intervals. The cumulative row exports the running total each time: 12, 21, 36, 47, 61 — a rising staircase. The delta row exports only the change since the previous collection: 12, 9, 15, 11, 14 — bars that rise and fall with traffic. The third export fails on both rows, drawn as a dashed outline. The cumulative series self-heals because the next export restates the total at 47, which still includes the fifteen measurements from the lost window. The delta series never recovers those fifteen measurements: the window they belonged to was released after the failed export. One counter, five collections — the same measurements, two encodings recorded per interval: 12, 9, 15, 11, 14 cumulative running total since start 12 21 36 not sent 47 61 delta change since last export 12 9 15 lost 11 14 t1 · 15 s t2 · 30 s t3 · export fails t4 · 60 s t5 · 75 s After the failed export the cumulative series self-heals at t4 — 47 restates the total. The delta window is gone for good.
Cumulative restates the running total on every export, so a dropped batch costs nothing; delta ships each window once, so a dropped batch loses it permanently.

Aggregation and Views

Between an instrument and its export sits an aggregation. Counters use a sum aggregation, histograms use an explicit-bucket aggregation, and gauges use a last-value aggregation; these defaults are applied automatically. A View overrides any of them for a matched instrument. The three highest-value uses are pinning histogram bucket boundaries to your real latency profile, allow-listing attribute keys to cap the number of time series an instrument can produce, and renaming or dropping an instrument you do not control.

A View is a selector plus a stream configuration. The selector can match on instrument_name (including * and ? wildcards), instrument_type, meter_name, or meter_version; at least one selector must be present or the SDK raises at construction. The stream side sets name, description, attribute_keys, and aggregation. Views are evaluated in order and every matching View produces a stream, so a wildcard View and a specific View can both apply to one instrument — order specific Views before wildcard ones and keep the set small enough to reason about. DropAggregation is the blunt instrument for silencing a noisy metric from a third-party instrumentation package without patching it, and ExponentialBucketHistogramAggregation is worth considering when the backend supports it, because it gives high-resolution quantiles without you having to guess boundaries in advance.

One rule catches people out: aggregation is bound to an instrument the first time a measurement is recorded through it. Adding or editing a View after the provider is running has no effect on already-created instruments. Views are startup configuration, not runtime configuration.

Which View fixes which problem Start from the question of what is actually wrong with an instrument's exported stream. If the problem is cardinality — one time series per unique attribute combination — the fix is a View with an attribute_keys allow-list that keeps only bounded keys. If the problem is shape — the default bucket boundaries do not straddle the threshold you alert on — the fix is a View with an ExplicitBucketHistogramAggregation, or an ExponentialBucketHistogramAggregation where the backend supports it. If the problem is noise from an instrumentation package you do not control, the fix is DropAggregation, which silences the instrument without patching the library. All three are startup configuration: Views are evaluated in order and bind when the first measurement is recorded. What is wrong with this stream? name the defect before writing the View Cardinality one series per unique attribute combination Shape buckets miss the threshold you alert on Noise a metric from a library you do not control attribute_keys={…} allow-list bounded keys only Explicit or Exponential BucketHistogramAggregation DropAggregation silence it without a patch Views are startup configuration: evaluated in order, bound at the first record() — put specific selectors before wildcards.
Pick the View from the defect, not the other way round — and register every one of them when the provider is constructed.

Step-by-step implementation

Step 1 — Build the Resource. Resource attributes are the top-level dimensions every metric is grouped by. Use semantic conventions for service identity, and build this object once so the same instance can be handed to a TracerProvider too — identical resource attributes across signals are what let a backend join a metric to a trace.

import os
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes

resource = Resource.create({
    ResourceAttributes.SERVICE_NAME: os.getenv("SERVICE_NAME", "checkout-service"),
    ResourceAttributes.SERVICE_VERSION: os.getenv("SERVICE_VERSION", "3.1.0"),
    ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("DEPLOYMENT_ENV", "production"),
})

Step 2 — Configure the OTLP exporter with a temporality preference. The gRPC exporter targets the Collector. The temporality preference maps each instrument kind to delta or cumulative. Note the argument takes the SDK instrument classes, not the API ones — importing Counter from opentelemetry.metrics instead of opentelemetry.sdk.metrics produces a mapping that silently never matches.

from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import Counter, Histogram, ObservableGauge
from opentelemetry.sdk.metrics.export import AggregationTemporality

# Prefer delta for additive instruments, cumulative for gauges.
temporality = {
    Counter: AggregationTemporality.DELTA,
    Histogram: AggregationTemporality.DELTA,
    ObservableGauge: AggregationTemporality.CUMULATIVE,
}

exporter = OTLPMetricExporter(
    endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"),
    insecure=False,
    timeout=10,
    preferred_temporality=temporality,
)

Step 3 — Wrap the exporter in a periodic reader. The interval is the cadence at which all instruments are collected and observable callbacks fire. Keep the timeout comfortably below the interval so a stalled export cannot overlap the next collection.

from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader

reader = PeriodicExportingMetricReader(
    exporter,
    export_interval_millis=15000,  # collect + export every 15s
    export_timeout_millis=10000,
)

Step 4 — Define Views for aggregation control. This View pins explicit latency buckets on a duration histogram; the second restricts every instrument to a bounded attribute set, which is the cheapest possible defence against a cardinality incident. Boundaries should straddle the thresholds you alert on — if the objective is "99% under 250 ms", 250 must be a boundary, because a quantile estimated from buckets is only as precise as the bucket edges near it.

from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation

latency_view = View(
    instrument_name="http.server.duration",
    aggregation=ExplicitBucketHistogramAggregation(
        boundaries=[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]
    ),
)

# Allow-list attributes everywhere else to bound cardinality.
bounded_attrs = View(instrument_name="*", attribute_keys={"http.method", "http.route"})

Step 5 — Construct and register the MeterProvider. Pass the resource, the reader, and the Views, then set it globally. This is the only place configuration is assembled, and it must run before the first get_meter() call anywhere in the process.

from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider

provider = MeterProvider(
    resource=resource,
    metric_readers=[reader],
    views=[latency_view, bounded_attrs],
    shutdown_on_exit=True,
)
metrics.set_meter_provider(provider)

Step 6 — Acquire a Meter and instruments. Name the Meter after the module so the instrumentation scope is meaningful. Create instruments once and reuse the objects; creating the same instrument repeatedly inside a request handler wastes work and, if the name and unit ever disagree between call sites, produces a duplicate-instrument warning and two conflicting streams.

meter = metrics.get_meter(__name__, "3.1.0")

request_counter = meter.create_counter(
    "http.server.request.count",
    unit="{request}",
    description="Total HTTP requests handled",
)
latency_hist = meter.create_histogram(
    "http.server.duration",
    unit="ms",
    description="HTTP server request duration",
)

Step 7 — Register observable instruments and shut down cleanly. Callbacks take a CallbackOptions argument and yield Observation objects; the return value is read once per collection. At exit, flush explicitly so the last partial window is not discarded.

from opentelemetry.metrics import CallbackOptions, Observation

def pool_depth(options: CallbackOptions):
    yield Observation(pool.checked_out(), {"pool": "primary"})

meter.create_observable_gauge(
    "db.pool.in_use",
    callbacks=[pool_depth],
    unit="{connection}",
)

def shutdown_metrics() -> None:
    provider.force_flush(timeout_millis=5000)
    provider.shutdown()
The seven bootstrap steps, and the point of no return Step one builds a Resource carrying service name, version and environment, shared with the TracerProvider. Step two configures an OTLPMetricExporter with an endpoint and a temporality preference keyed on SDK instrument classes. Step three wraps that exporter in a PeriodicExportingMetricReader whose export timeout stays below its interval. Step four defines Views that pin histogram boundaries and allow-list attribute keys. Step five constructs the MeterProvider from resource, readers and views, then registers it with set_meter_provider. A dashed line after step five marks the point of no return: from here the global provider cannot be replaced, so every later get_meter call must come after it. Step six acquires a Meter named after the module and creates each instrument once. Step seven force-flushes and shuts the provider down so the final aggregation window is exported instead of discarded. 1 Resource.create({service.name, service.version, deployment.environment}) build it once and hand the same object to the TracerProvider so both signals agree on identity 2 OTLPMetricExporter(endpoint, preferred_temporality) the temporality map is keyed on SDK instrument classes — the API classes never match 3 PeriodicExportingMetricReader(exporter, export_interval_millis) starts a background timer thread; keep export_timeout_millis comfortably below the interval 4 View(instrument_name=…, aggregation=…, attribute_keys=…) pin bucket boundaries and bound the attribute set before a single measurement is recorded 5 MeterProvider(resource, metric_readers, views) → set_meter_provider() the one place configuration is assembled, and the only call that publishes it to the process point of no return — a second set_meter_provider() is logged and ignored 6 metrics.get_meter(__name__, version) → create_counter / create_histogram every get_meter() before step 5 silently returns a no-op Meter; create instruments once and reuse them 7 provider.force_flush(timeout_millis=5000) → provider.shutdown() the final aggregation window only leaves the process if you flush it before the interpreter exits Steps 1–5 run exactly once per process — and once per worker, after the fork, in a prefork server.
Bootstrap order is the configuration: everything above the dashed line must happen before the first instrument exists.

Configuration reference

Parameter Type Default Production value
MeterProvider(resource=...) Resource auto-detected, service.name unset explicit service.name, service.version, deployment.environment
MeterProvider(metric_readers=...) list of readers [] — nothing is exported exactly one PeriodicExportingMetricReader
MeterProvider(views=...) sequence of View () latency buckets plus an attribute allow-list
MeterProvider(shutdown_on_exit=...) bool True True, plus an explicit flush in the shutdown hook
get_meter(name, version) str, str required __name__ and the package version
PeriodicExportingMetricReader(export_interval_millis=...) int (ms) 60000 1500030000
PeriodicExportingMetricReader(export_timeout_millis=...) int (ms) 30000 10000, always below the interval
OTLPMetricExporter(endpoint=...) str host:port localhost:4317 otel-collector:4317
OTLPMetricExporter(insecure=...) bool False False; True only on a trusted local network
OTLPMetricExporter(timeout=...) int (s) 10 10
OTLPMetricExporter(headers=...) dict or tuple None auth headers when exporting direct to a vendor
preferred_temporality={...} dict of instrument class to temporality cumulative for all kinds delta for Counter and Histogram when the backend aggregates
preferred_aggregation={...} dict of instrument class to aggregation per-instrument defaults exponential histogram where the backend supports it
View(attribute_keys=...) set of str None — every attribute kept explicit allow-list of bounded keys
View(aggregation=ExplicitBucketHistogramAggregation(boundaries=...)) list of float [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000] boundaries straddling your objective thresholds

The environment variables below are read by the SDK when the corresponding constructor argument is omitted, which is what makes one image deployable to several environments.

Environment variable Effect Example
OTEL_SERVICE_NAME Sets service.name on the Resource checkout-service
OTEL_RESOURCE_ATTRIBUTES Comma-separated extra resource attributes service.version=3.1.0,deployment.environment=prod
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT Metrics-only OTLP target, overrides the shared endpoint http://otel-collector:4317
OTEL_EXPORTER_OTLP_METRICS_HEADERS Headers for the metrics exporter only api-key=...
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE Global temporality preference delta, cumulative, or lowmemory
OTEL_METRIC_EXPORT_INTERVAL Periodic reader interval in milliseconds 15000
OTEL_METRIC_EXPORT_TIMEOUT Per-export deadline in milliseconds 10000
OTEL_METRICS_EXEMPLAR_FILTER Which measurements may attach trace exemplars trace_based
OTEL_SDK_DISABLED Turns the whole SDK into a no-op true in unit-test runs

Note that the endpoint environment variable takes a full URL with a scheme, while the endpoint= constructor argument on the gRPC exporter takes a bare host:port. Mixing the two forms is the single most common cause of an exporter that connects to nothing.

How one setting resolves, tier by tier Resolution order runs top to bottom and the first match wins. Tier one is the constructor argument, explicit in code. Tier two is the signal-specific environment variable, the metrics-only form. Tier three is the shared OTLP environment variable used by every signal. Tier four is the SDK default. Two settings are resolved side by side. The export interval is passed as export_interval_millis in code, so tier one wins and OTEL_METRIC_EXPORT_INTERVAL is ignored, along with the sixty-second default. The OTLP endpoint is not passed in code, so tier one falls through and OTEL_EXPORTER_OTLP_METRICS_ENDPOINT wins at tier two, shadowing the shared OTEL_EXPORTER_OTLP_ENDPOINT and the localhost default. resolution order — first match wins export interval OTLP endpoint 1 · Constructor argument explicit in code — always wins export_interval_millis=15000 used endpoint= not passed falls through 2 · Signal-specific env var the metrics-only form of the variable OTEL_METRIC_EXPORT_INTERVAL ignored — code already won …OTLP_METRICS_ENDPOINT used 3 · Shared OTLP env var read by traces, metrics and logs alike no shared equivalent interval is metrics-only OTEL_EXPORTER_OTLP_ENDPOINT shadowed by tier 2 4 · SDK default what you get when nothing is set 60000 ms never reached localhost:4317 never reached
Every setting resolves down the same four tiers — which is what lets one image carry code-level policy and deployment-level addressing at the same time.

Async & concurrency considerations

Synchronous instrument calls (add, record) are thread-safe and non-blocking; the SDK aggregates in memory and the export happens on the reader's background thread, so calling request_counter.add(1, {...}) inside an asyncio coroutine never awaits network I/O. There is no context propagation involved either — unlike a span, a measurement does not need to find an ambient parent, so recording from inside a task, a thread-pool worker, or a callback all behave identically. This is why metrics survive the async patterns that break tracing context, described in async tracing patterns in Python.

Observable callbacks, by contrast, are invoked by the reader thread on each interval, so keep them fast and side-effect free; do not perform blocking I/O or acquire contended locks inside a callback, or you will stall collection for every instrument. The callback receives a CallbackOptions carrying a timeout_millis budget, and the honest pattern is to read a value that some other part of the system already maintains — a pool object's counter, a cached gauge refreshed by the event loop — rather than computing it on demand. A callback that queries a database is a scheduled outage waiting for a slow query.

If you fork worker processes (Gunicorn, Celery prefork), construct the MeterProvider after the fork so each worker owns its own reader thread and exporter connection; a provider created in the parent shares gRPC channels and file descriptors across children, and the usual symptom is that exports work for a while and then stop entirely. This mirrors the post-fork initialization rule from OpenTelemetry SDK setup for tracing. In Gunicorn the hook is post_fork; in FastAPI or another ASGI app run by Uvicorn workers, the lifespan startup handler runs per worker and is the right place.

# gunicorn.conf.py — one provider, one reader thread, per worker
def post_fork(server, worker):
    from telemetry import init_metrics  # imports the SDK lazily, after fork
    init_metrics()

Because each worker exports its own series, the backend receives one data point per worker per instrument-attribute combination. That is correct and desirable: you can sum across workers at query time, and a single misbehaving worker stays visible instead of being averaged away. The cost is that resource attributes alone no longer uniquely identify a series, so include a worker or instance identifier in the resource when you need to disambiguate — and be aware that the OpenTelemetry SDK has no shared-memory aggregation mode equivalent to prometheus_client's multiprocess directory, so per-worker series is the only model on offer. Avoid the temptation to add a high-cardinality per-request identifier to bound this; the right level of cardinality is per worker and per bounded label set, and the attribute discipline that enforces it is the same one described in controlling label cardinality and in recording counters and histograms with OpenTelemetry.

One more interaction is worth knowing: when a measurement is recorded inside a sampled span, the SDK can attach an exemplar carrying that trace and span ID to the bucket the value fell into, governed by OTEL_METRICS_EXEMPLAR_FILTER. That is the mechanism that lets a spike on a latency panel link straight to an example trace, and it is the metrics-side counterpart of adding trace IDs to log records.

Threads inside a worker, and the fork boundary around it Inside one worker process, two asyncio request handlers and a thread-pool worker all call add and record on the same instruments. Those calls land in a shared in-memory aggregation holding one accumulator per attribute set, with no lock contention and no network I/O. A single reader thread wakes on the export interval, runs every observable callback, aggregates and pushes one OTLP batch to the Collector, then sleeps again. Below a dashed fork boundary, a second panel shows the failure mode: a MeterProvider constructed before the fork leaves children sharing one gRPC channel and its file descriptors, so exports work briefly and then stop entirely — the fix is to build the provider in post_fork or in the ASGI lifespan startup handler. On the right, the Collector receives one OTLP stream per worker, which is summed at query time because the SDK has no shared-memory aggregation mode. One worker process — one provider, one reader thread asyncio handler A asyncio handler B thread-pool worker in-memory aggregation one accumulator per attribute set no context, no I/O reader thread wakes every 15 s runs observable callbacks exports, then sleeps add() and record() are thread-safe and non-blocking — they touch an accumulator, never the network. OTLP batch fork boundary — Gunicorn post_fork, Celery prefork, Uvicorn workers If the provider is built BEFORE the fork children inherit one gRPC channel and its file descriptors exports succeed for a while, then stop entirely fix: initialize in post_fork or in lifespan startup Collector one OTLP stream per worker sum across workers at query time — no shared aggregation
Recording happens on your threads, exporting happens on one reader thread — and both must be created after the fork, once per worker.

Production code examples

End-to-end: record, collect, and export

This program initializes the full pipeline, registers an observable gauge for connection-pool depth, records on a counter and histogram, and forces a flush so the export is visible immediately.

import os
import time
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader

# 1. Identity
resource = Resource.create({
    ResourceAttributes.SERVICE_NAME: "checkout-service",
    ResourceAttributes.SERVICE_VERSION: "3.1.0",
})

# 2. Exporter -> reader -> provider
exporter = OTLPMetricExporter(endpoint="otel-collector:4317", insecure=True)
reader = PeriodicExportingMetricReader(exporter, export_interval_millis=15000)
provider = MeterProvider(resource=resource, metric_readers=[reader])
metrics.set_meter_provider(provider)

# 3. Instruments
meter = metrics.get_meter(__name__, "3.1.0")
requests = meter.create_counter("http.server.request.count", unit="{request}")
latency = meter.create_histogram("http.server.duration", unit="ms")

# 4. Observable gauge: read pool depth on each collection
def read_pool(options):
    from opentelemetry.metrics import Observation
    yield Observation(7, {"pool": "primary"})

meter.create_observable_gauge("db.pool.in_use", callbacks=[read_pool], unit="{connection}")

# 5. Simulate traffic
for _ in range(50):
    requests.add(1, {"http.route": "/checkout", "http.status_code": 200})
    latency.record(42.5, {"http.route": "/checkout"})
    time.sleep(0.01)

# 6. Flush deterministically and shut down
provider.force_flush()
provider.shutdown()

Expected Output:

{
  "resourceMetrics": [{
    "resource": {
      "attributes": [
        {"key": "service.name", "value": {"stringValue": "checkout-service"}},
        {"key": "service.version", "value": {"stringValue": "3.1.0"}}
      ]
    },
    "scopeMetrics": [{
      "scope": {"name": "__main__", "version": "3.1.0"},
      "metrics": [
        {
          "name": "http.server.request.count",
          "unit": "{request}",
          "sum": {
            "isMonotonic": true,
            "aggregationTemporality": "AGGREGATION_TEMPORALITY_CUMULATIVE",
            "dataPoints": [{
              "asInt": "50",
              "attributes": [
                {"key": "http.route", "value": {"stringValue": "/checkout"}},
                {"key": "http.status_code", "value": {"intValue": "200"}}
              ]
            }]
          }
        },
        {
          "name": "http.server.duration",
          "unit": "ms",
          "histogram": {
            "aggregationTemporality": "AGGREGATION_TEMPORALITY_CUMULATIVE",
            "dataPoints": [{
              "count": "50",
              "sum": 2125.0,
              "bucketCounts": ["0", "0", "0", "50", "0"],
              "explicitBounds": [10, 25, 50, 100]
            }]
          }
        },
        {
          "name": "db.pool.in_use",
          "unit": "{connection}",
          "gauge": {
            "dataPoints": [{
              "asInt": "7",
              "attributes": [{"key": "pool", "value": {"stringValue": "primary"}}]
            }]
          }
        }
      ]
    }]
  }]
}

Fork-safe bootstrap for an ASGI service

In a real service the pipeline is built once per worker, after the fork, and torn down on shutdown. This module is imported by the app but does its work only when called from the lifespan handler, which keeps import order irrelevant.

# telemetry.py
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter

_provider: MeterProvider | None = None

def init_metrics() -> MeterProvider:
    """Build and register the provider. Call once per worker, after fork."""
    global _provider
    if _provider is not None:          # idempotent: reload-safe under --reload
        return _provider
    resource = Resource.create({"service.name": "checkout-service"})
    reader = PeriodicExportingMetricReader(
        OTLPMetricExporter(endpoint="otel-collector:4317", insecure=True),
        export_interval_millis=15000,
    )
    latency_view = View(
        instrument_name="http.server.duration",
        aggregation=ExplicitBucketHistogramAggregation(
            boundaries=[5, 10, 25, 50, 100, 250, 500, 1000]
        ),
    )
    _provider = MeterProvider(
        resource=resource, metric_readers=[reader], views=[latency_view]
    )
    metrics.set_meter_provider(_provider)
    return _provider

def shutdown_metrics() -> None:
    if _provider is not None:
        _provider.force_flush(timeout_millis=5000)  # last window reaches the Collector
        _provider.shutdown()
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from opentelemetry import metrics
import telemetry

@asynccontextmanager
async def lifespan(app: FastAPI):
    telemetry.init_metrics()                 # per-worker, post-fork
    meter = metrics.get_meter(__name__)
    app.state.requests = meter.create_counter("http.server.request.count")
    yield
    telemetry.shutdown_metrics()             # flush before the worker exits

app = FastAPI(lifespan=lifespan)

@app.get("/checkout")
async def checkout():
    app.state.requests.add(1, {"http.route": "/checkout"})
    return {"ok": True}

Expected Output:

INFO:     Started server process [8123]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
# every 15s the Collector logs a received batch:
2026-07-25T10:14:02Z info MetricsExporter {"resource metrics": 1, "metrics": 1, "data points": 1}

Console export for local debugging

When you cannot reach a Collector, swap the OTLP exporter for the console exporter to print the same payload to stdout. Everything else in the pipeline is identical, which makes this the fastest way to confirm that instruments, views, and attributes are shaped the way you think before any network is involved.

from opentelemetry.sdk.metrics.export import (
    ConsoleMetricExporter,
    PeriodicExportingMetricReader,
)

reader = PeriodicExportingMetricReader(
    ConsoleMetricExporter(),
    export_interval_millis=5000,
)

Expected Output:

{"resource_metrics": [{"resource": {"service.name": "checkout-service"},
 "scope_metrics": [{"scope": {"name": "__main__"},
 "metrics": [{"name": "http.server.request.count", "data":
 {"data_points": [{"value": 50, "attributes": {"http.route": "/checkout"}}]}}]}]}]}

For automated tests, prefer InMemoryMetricReader over the console exporter: attach it to a locally built MeterProvider, record measurements, then call reader.get_metrics_data() and assert on the aggregated points directly. Nothing is written, nothing is exported, and no global state leaks between test cases.

Which SDK object produced each part of the payload A single exported OTLP payload is shown as three stacked sections. The resource section carries service.name checkout-service and service.version 3.1.0, and is produced by Resource.create — one object shared with the TracerProvider. The scope section carries the name __main__ and version 3.1.0, and comes from the get_meter call that created the Meter. The metric section holds http.server.duration with unit ms, a cumulative aggregation temporality, a count of fifty, a sum of 2125.0, explicit bounds of 10, 25, 50 and 100 and bucket counts of 0, 0, 0, 50, 0. The instrument itself comes from create_histogram, the temporality from the exporter's preferred_temporality mapping, and the explicit bounds from the View, which binds at the first record call. one exported OTLP payload the SDK object that produced it resourceMetrics[0].resource service.name = checkout-service service.version = 3.1.0 scopeMetrics[0].scope name = __main__ · version = 3.1.0 metrics[0] — http.server.duration unit = ms · histogram aggregationTemporality = CUMULATIVE count = 50 · sum = 2125.0 explicitBounds = [10, 25, 50, 100] bucketCounts = [0, 0, 0, 50, 0] Resource.create() one object, shared with traces get_meter(name, version) the instrumentation scope create_histogram() plus preferred_temporality on the OTLP exporter View(boundaries=[…]) sets explicitBounds, and binds at the first record() ConsoleMetricExporter and InMemoryMetricReader emit exactly this structure — only the transport changes.
Read an exported payload backwards and every field points at the object that configured it — which is how you tell a View problem from an exporter problem.

Common mistakes

No MeterProvider configured, metrics are dropped (or silently no data). The SDK falls back to a no-op provider if you call get_meter() before set_meter_provider(). Root cause: instrument creation happened at import time, before bootstrap ran. Remediation: build and register the provider first, then acquire Meters; in frameworks, do it in a startup hook and create instruments there.

Overriding of current MeterProvider is not allowed. A second bootstrap path called set_meter_provider() again — commonly a test fixture, an auto-instrumentation agent, or a module reloaded by --reload. Root cause: registration is one-way per process. Remediation: make initialization idempotent behind a module-level guard, and in tests build a local provider with InMemoryMetricReader instead of touching the global.

Observable callback raises and metric vanishes. A callback that raises inside the reader thread drops that instrument's data point for the interval and logs an exception. Root cause: blocking I/O, missing keys, or a callback that returns a value instead of an iterable of Observation objects. Remediation: make callbacks pure and fast, yield Observation objects, and guard external lookups with cached values.

Histogram buckets look wrong or are the defaults. A View set the buckets but the metric name in the View did not match the instrument. Root cause: instrument_name mismatch (typo or wrong casing), or the View was added after the first record() bound the aggregation. Remediation: set instrument_name to the exact instrument name, or match with a * wildcard plus instrument_type, and register every View when constructing the provider.

Last batch never arrives. A short-lived script or a container receiving SIGTERM exits before the next export interval. Root cause: no flush on shutdown. Remediation: call provider.force_flush() and provider.shutdown() in the shutdown path — and remember that a hard SIGKILL after the grace period gives you no chance to flush, so keep the interval shorter than the orchestrator's termination grace.

Exploding series count. Per-request unique values (user IDs, full URLs) become attributes and create one time series each, and under cumulative temporality every one of them is retained for the life of the process. Root cause: unbounded attribute cardinality. Remediation: use a View with attribute_keys to allow only bounded labels, the same discipline applied in recording counters and histograms with OpenTelemetry.

Which stage of the pipeline each symptom comes from Six pipeline stages, each paired with the symptom it produces. Bootstrap order produces no data at all, because get_meter ran at import time before set_meter_provider; create instruments in a startup hook instead. Provider registration produces the overriding-is-not-allowed warning when a second bootstrap path calls set_meter_provider, typically a test fixture or an auto-reload; guard initialization and use a local provider in tests. View binding produces default histogram buckets when instrument_name does not match or the View was added after the first record; register every View when constructing the provider. Callback execution produces a missing gauge series when an observable callback raises or returns a plain value; yield Observation objects and keep the callback fast. Aggregation retention produces steady memory growth, because cumulative temporality keeps every attribute set it has ever seen; allow-list attribute keys. Shutdown produces a missing final batch when the process exits inside an export interval; force_flush and then shutdown. every symptom below is silent — nothing raises, the data simply never arrives 1 · bootstrap order no data at all get_meter() ran at import, before the provider existed fix: build in a startup hook 2 · registration override not allowed a second set_meter_provider() from a fixture or --reload fix: guard init, be idempotent 3 · View binding default buckets instrument_name mismatch, or the View came too late fix: register Views up front 4 · callback execution gauge series missing the callback raised, or gave a value, not Observations fix: yield, and stay fast 5 · aggregation state memory grows all day cumulative retains every attribute set ever seen fix: allow-list attribute_keys 6 · shutdown last batch never arrives the process exited inside an export interval fix: force_flush, then shutdown Read the symptom, then fix the stage — patching the instrument almost never helps, because the instrument is rarely where it broke.
Six silent failures, each traced back to the stage of the pipeline that actually produced it.

Frequently Asked Questions

When should I use delta temporality instead of cumulative?

Use delta temporality when your backend expects per-interval values, such as some hosted OTLP endpoints and Prometheus remote-write gateways that recompute rates. Use cumulative when exporting to Prometheus scraping or any store that tracks monotonic totals, since it tolerates dropped exports without losing the running sum.

Do I need a separate Meter per module?

Get one Meter per instrumentation scope, typically named after the module or library using the dunder name. The scope name and version appear on exported metrics and help you attribute data to the code that produced it.

Why are my observable gauge callbacks never called?

Observable callbacks only fire when the PeriodicExportingMetricReader collects, which happens on its export interval. If the process exits before the first interval or you never registered the callback on a real instrument, no data is collected. Lower the interval or call force_flush before shutdown.

Can I change histogram buckets after the SDK is running?

Bucket boundaries are fixed at MeterProvider construction through a View with an explicit bucket histogram aggregation. To change them you must rebuild the provider, because the aggregation is bound to the instrument when the first measurement is recorded.

Does the metrics SDK need its own initialization if I already configured tracing?

Yes. The TracerProvider and the MeterProvider are separate globals with separate exporters and readers, even when they share one Resource and one Collector endpoint. Build the Resource once and pass the same object to both providers so traces and metrics agree on service identity.

How do I run the SDK without any Collector during tests?

Attach an InMemoryMetricReader to a locally constructed MeterProvider, record measurements, then call get_metrics_data to assert on the aggregated points. It needs no network, no background thread, and no global provider, so tests stay isolated and deterministic.