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.
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.
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.
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.
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()
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 |
15000–30000 |
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.
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.
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.
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.
Related
- Python Metrics and Instrumentation — the parent guide covering instrument choice, cardinality, transport, and cost together.
- Exporting OTLP metrics to the Collector — the export hop in detail, including TLS and Collector receiver configuration.
- Recording counters and histograms with OpenTelemetry — instrument-level recipes for the instruments this page configures.
- OpenTelemetry vs Prometheus for Python metrics — the push-versus-pull decision and the Prometheus reader bridge.
- Metric types and cardinality — choosing the right instrument and bounding its attribute set.
- OpenTelemetry SDK setup for tracing — the tracing provider that shares this Resource and Collector endpoint.
- Configuring views and aggregation in OpenTelemetry metrics — custom buckets, attribute allowlists, and disabling an instrument you cannot remove.
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.