Exporting OTLP Metrics to the Collector
Getting aggregated metrics out of a Python process and into the OpenTelemetry Collector comes down to pairing an OTLPMetricExporter with a PeriodicExportingMetricReader and matching that pair to a receiver on the Collector side. This walkthrough is for backend engineers and SREs who already record instruments and now need the export hop to be correct, secure, and survivable across restarts. It sits under the OpenTelemetry metrics SDK, which covers the full provider lifecycle, and is part of the broader Python metrics and instrumentation reference; the instruments whose values travel over this hop are created as described in recording counters and histograms with OpenTelemetry.
Prerequisites
Python 3.10 or newer, an OpenTelemetry Collector you can reach on the network, and the SDK plus the gRPC OTLP exporter installed with pinned ranges. The exporter package and the SDK are released together, so pin them to the same range to avoid a protocol mismatch after an unrelated upgrade.
pip install \
"opentelemetry-sdk>=1.30.0,<2.0.0" \
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"
Every knob shown below can also come from the environment, which is the form to prefer in a container image so one build runs unchanged in every environment:
export OTEL_SERVICE_NAME="checkout-api" # becomes the service.name resource attribute
export OTEL_EXPORTER_OTLP_ENDPOINT="otel-collector:4317" # gRPC: host:port, no scheme
export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE="delta"
export OTEL_METRIC_EXPORT_INTERVAL="15000" # milliseconds
export OTEL_METRIC_EXPORT_TIMEOUT="10000" # milliseconds
export OTEL_EXPORTER_OTLP_CERTIFICATE="/etc/otel/ca.pem" # CA bundle for TLS
export OTEL_EXPORTER_OTLP_COMPRESSION="gzip" # optional, cuts payload size
The resource identity matters as much as the endpoint. Whatever OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES carry is attached to every exported metric and is what the Collector's processors and the backend group on, so set them before worrying about tuning intervals — an export that arrives with service.name of unknown_service is technically successful and operationally useless.
Implementation
Five steps take a running MeterProvider from recording values in memory to landing them in a Collector pipeline.
Step 1 — Construct the exporter against the Collector endpoint. The gRPC exporter takes a bare host:port, not a URL. Keep insecure=False for any non-local network and rely on TLS credentials; use insecure=True only against a Collector on the same host or inside the same pod network namespace.
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
exporter = OTLPMetricExporter(
endpoint="otel-collector:4317", # host:port, no scheme
insecure=False, # keep TLS on in production
timeout=10, # seconds per export attempt
headers=(("x-tenant", "team-checkout"),), # optional auth/routing headers
)
If you must speak OTLP over HTTP instead — a proxy that only forwards HTTP, or a backend that exposes no gRPC port — install opentelemetry-exporter-otlp-proto-http and import OTLPMetricExporter from opentelemetry.exporter.otlp.proto.http.metric_exporter. That variant expects a full URL including the path, http://otel-collector:4318/v1/metrics, and the two are not interchangeable: passing the gRPC form to the HTTP exporter or vice versa is the single most common wiring error on this page.
Step 2 — Set a temporality preference. Delta keeps payloads small and suits backends that recompute rates per interval; cumulative re-states running totals and therefore survives a dropped export. Choose per instrument kind rather than globally, because the right answer differs between synchronous counters and observable ones.
from opentelemetry.sdk.metrics import Counter, Histogram, ObservableCounter
from opentelemetry.sdk.metrics.export import AggregationTemporality
preferred = {
Counter: AggregationTemporality.DELTA,
Histogram: AggregationTemporality.DELTA,
ObservableCounter: AggregationTemporality.CUMULATIVE,
}
exporter = OTLPMetricExporter(endpoint="otel-collector:4317", preferred_temporality=preferred)
The choice is not cosmetic. A Prometheus-backed pipeline ultimately wants cumulative series, because rate() is defined over a monotonic counter and the Collector's Prometheus exporter has to reconstruct one; a delta-native gateway wants deltas so it can sum windows without tracking per-series state. If the two ends disagree, metrics still arrive and dashboards still render — they simply show numbers that are wrong by a factor related to the interval, which is far harder to notice than a connection error. Decide from the backend inward, and if you are still weighing which storage system sits at the end of the pipeline, OpenTelemetry vs Prometheus for Python metrics frames that decision.
Step 3 — Wrap the exporter in a periodic reader and register the provider. The reader owns the export interval and a per-export timeout; the provider owns the reader.
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
reader = PeriodicExportingMetricReader(
exporter,
export_interval_millis=15000, # collect + send every 15s
export_timeout_millis=10000, # deadline for the whole cycle
)
provider = MeterProvider(metric_readers=[reader])
metrics.set_meter_provider(provider)
The interval and timeout interact. export_interval_millis is how often the reader collects every instrument and ships a payload; export_timeout_millis is the deadline for that whole collect-and-send cycle. The timeout must be comfortably smaller than the interval, or a slow export overlaps the next one and the reader falls behind. A 15-second interval with a 10-second timeout leaves headroom for a retry inside the cycle.
The reader runs export on its own background thread, woken every interval, and it does not maintain a queue of pending batches across cycles. This is the crucial backpressure property: if a cycle takes longer than the interval the reader simply starts the next collection late rather than letting work pile up, so a struggling Collector slows the cadence instead of growing unbounded memory in the process. That is a deliberate contrast with the tracing side of the SDK, where the span processor described in the OpenTelemetry SDK setup guide buffers spans in a bounded queue and drops on overflow — metrics are pre-aggregated, so the newest collection already contains everything the last one would have said.
Within a single cycle the gRPC exporter handles transient failures itself. A StatusCode.UNAVAILABLE or DEADLINE_EXCEEDED is treated as retryable and retried with exponential backoff, starting near one second and roughly doubling, until either the export succeeds or the cumulative time approaches export_timeout_millis, at which point the batch is abandoned. Non-retryable codes such as INVALID_ARGUMENT fail immediately without retry, because resending an identical malformed payload cannot help. Because the abandoned batch is never re-queued, the timeout is effectively your data-loss budget per outage: a longer timeout buys more in-cycle retries against a flapping Collector but eats into the interval headroom, so size the two together rather than independently.
Step 4 — Enable the matching receiver on the Collector. The receiver listens on 4317 for gRPC; a pipeline routes metrics from the receiver through a batch processor to an exporter. This configuration logs metrics so you can confirm arrival before adding a real backend.
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
batch:
timeout: 10s
exporters:
debug:
verbosity: detailed
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [debug]
The port on both sides must agree, and so must the protocol: a Collector with only the http protocol block enabled answers on 4318 and will refuse a gRPC connection on 4317 with a connection error rather than a protocol error, which sends people hunting for firewall rules that are not the problem.
Step 5 — Flush the last window on shutdown. The reader exports on a fixed cadence, so a process killed mid-interval loses whatever it has collected since the last export. Register a shutdown path that drains it, and remember that a rolling deployment exercises this code on every single pod.
import atexit
import signal
def _drain(*_args):
provider.force_flush(timeout_millis=5000) # export what is pending now
provider.shutdown() # stop the reader thread cleanly
atexit.register(_drain)
signal.signal(signal.SIGTERM, _drain) # containers are stopped with SIGTERM
Expected Output: the flush runs before exit and the final window arrives.
INFO opentelemetry.sdk.metrics.export MetricExporter started; endpoint=otel-collector:4317 tls=enabled
INFO opentelemetry.sdk.metrics.export force_flush completed in 41ms; 2 metrics exported
Securing the channel
In production the exporter should authenticate the Collector and encrypt the link. Leave insecure at its default and pass a CA certificate so the gRPC channel verifies the server, then attach credentials or headers for tenant routing if your Collector fronts multiple teams. Prefer the OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_CERTIFICATE environment variables over hardcoded values so the deployment system controls routing. When the Collector sits behind a load balancer, point the endpoint at the balancer and let it terminate or pass through TLS; the exporter multiplexes many exports over one long-lived gRPC connection, so keepalive settings on the balancer matter far more than connection count.
Headers are the mechanism for both authentication and multitenancy. The headers argument takes a tuple of key-value pairs attached as gRPC metadata on every export: a managed backend that accepts OTLP directly usually wants an authorization or vendor API-key header here, while a shared Collector uses a routing header such as x-tenant to fan traffic to the right pipeline. Headers can equally be supplied out of band through OTEL_EXPORTER_OTLP_HEADERS as a comma-separated key=value list, which keeps secrets out of the image. When you provide explicit TLS credentials, build them from the CA bundle and pass them as credentials, which takes precedence over the environment certificate path.
import grpc
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
with open("/etc/otel/ca.pem", "rb") as fh:
creds = grpc.ssl_channel_credentials(root_certificates=fh.read())
exporter = OTLPMetricExporter(
endpoint="otel-collector:4317",
credentials=creds, # verifies the server certificate
headers=(("authorization", "Bearer ${OTLP_TOKEN}"),),
timeout=10,
)
Exporting from forking servers and async apps
The reader's background thread does not survive fork(). Under Gunicorn or uWSGI, a provider built at import time in the master process leaves every worker with a reader that never wakes, so metrics accumulate in memory and nothing is ever exported — with no error to show for it. Build the provider inside the post_fork hook so each worker owns a live reader, and give each worker a distinguishing resource attribute if you need to tell their series apart downstream. The same rule applies to any multiprocessing pool that instruments its children.
Async services need no special handling on this hop. The reader thread performs the collect and the gRPC send outside the event loop, so an export never blocks a coroutine, and instrument calls such as counter.add() are non-blocking in-memory updates safe to make from inside async handlers. The one thing to keep off the loop is an observable callback that does I/O: callbacks run on the reader thread during collection, and a slow one delays the whole cycle for every instrument. Read a cached value there and refresh it elsewhere.
Configuration options
| Setting | Where | Default | Notes |
|---|---|---|---|
endpoint |
exporter / OTEL_EXPORTER_OTLP_ENDPOINT |
localhost:4317 |
gRPC uses host:port, no scheme |
insecure |
exporter | False |
True disables TLS; local only |
timeout |
exporter | 10 s |
per-export deadline |
headers |
exporter / OTEL_EXPORTER_OTLP_HEADERS |
none | auth, tenant routing |
compression |
exporter / OTEL_EXPORTER_OTLP_COMPRESSION |
none | gzip trades CPU for bandwidth |
preferred_temporality |
exporter / ..._TEMPORALITY_PREFERENCE |
cumulative | delta vs cumulative per instrument kind |
export_interval_millis |
reader / OTEL_METRIC_EXPORT_INTERVAL |
60000 |
collection cadence |
export_timeout_millis |
reader / OTEL_METRIC_EXPORT_TIMEOUT |
30000 |
whole-cycle deadline; keep below the interval |
Collector endpoint |
receiver YAML | 0.0.0.0:4317 |
must match the exporter port and protocol |
Explicit constructor arguments win over environment variables, which is what makes the environment a safe default layer: set the endpoint and certificate path in the deployment manifest, and override only what a specific service genuinely needs in code. Compression is worth enabling as soon as payloads carry histograms with many buckets across many attribute sets, since bucket counts compress extremely well; below that, the CPU cost is not repaid.
Verification
Run the Python process for one interval, then check the Collector's debug output. A successful delta export prints a resource block, a scope, and data points; the resource block is where you confirm service.name arrived intact.
Expected Output (Collector debug exporter):
2026-06-19T10:14:32Z info MetricsExporter {"kind": "exporter", "data_type": "metrics", "name": "debug", "resource metrics": 1, "metrics": 2, "data points": 2}
Metric #0
Descriptor:
-> Name: http.server.request.count
-> Unit: {request}
-> DataType: Sum
-> IsMonotonic: true
-> AggregationTemporality: Delta
NumberDataPoints #0
Data point attributes:
-> http.route: Str(/checkout)
Value: 50
Read three things off that output before declaring success: AggregationTemporality must be the value you configured, the data point attributes must be the bounded set you intended rather than an unbounded identifier, and the metric count must grow between cycles rather than staying at the handful the SDK emits on the first collection. An attribute you did not expect here is a cardinality problem arriving in your backend, and the remedy belongs upstream in the View configuration — see controlling label cardinality for the bounding techniques.
To verify the failure path without taking the Collector down, point the exporter's endpoint at a closed port and confirm the SDK logs a retry rather than crashing the application:
Expected Output (exporter retry log):
WARNING opentelemetry.exporter.otlp.proto.grpc.exporter Transient error StatusCode.UNAVAILABLE encountered while exporting metrics to otel-collector:4317, retrying in 1s.
A third check worth running once per service is the shutdown path: send SIGTERM to the process and confirm a final export lands in the Collector log within the flush timeout. Rolling deployments are the most frequent cause of ragged edges on dashboards, and this is the only test that exercises the code that prevents them.
Common mistakes
-
Error signature:
StatusCode.UNAVAILABLEon every export, forever. Root cause: the exporter cannot reach the Collector, usually because a scheme was included in the gRPC endpoint (http://otel-collector:4317) or because the Collector has only the HTTP protocol enabled on4318. Remediation: use a barehost:portfor the gRPC exporter and confirm the receiver'sprotocols.grpcblock listens on the same port. -
Error signature: metrics arrive but rates read as doubled, halved, or implausibly flat. Root cause: the exporter's temporality preference disagrees with what the backend behind the Collector expects, so windows are summed or differenced twice. Remediation: align
preferred_temporality(orOTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE) with the backend — cumulative for Prometheus-style stores, delta for per-interval gateways. -
Error signature: the final window is missing after every deploy, leaving a gap on the trailing edge of each panel. Root cause: a rolling restart kills the process before the next interval fires and nothing drained the reader. Remediation: call
force_flush()andshutdown()from aSIGTERMhandler, and make sure the container's stop grace period exceeds the flush timeout. -
Error signature: dashboards develop gaps with no error logs at all, and cycle durations creep toward the interval. Root cause:
export_timeout_milliswas set equal to or larger thanexport_interval_millis, so a slow Collector lets one cycle run into the next and the reader perpetually starts late. Remediation: keep the timeout comfortably below the interval — 10s against 15s is a good ratio — and if cycles are genuinely slow, lengthen the interval rather than the timeout so retries still fit inside one cycle. -
Error signature: a Gunicorn service reports metrics from no worker, or from only one. Root cause: the
MeterProviderwas built beforefork(), so the reader thread exists only in the master process. Remediation: construct the provider in thepost_forkhook so each worker starts its own reader, and add a worker-identifying resource attribute if the series must be distinguishable.
Related
- The OpenTelemetry metrics SDK in Python — the parent reference covering the provider, readers, views, and resource identity this export hop depends on.
- Recording counters and histograms with OpenTelemetry — creating the instruments whose aggregated values this exporter ships.
- OpenTelemetry vs Prometheus for Python metrics — push versus pull, and whether an OTLP Collector hop is the right pipeline at all.
- Controlling label cardinality in Prometheus — bounding the attribute sets that inflate every OTLP payload you export.
- OpenTelemetry SDK setup for Python — the tracing side of the same pipeline, sharing one endpoint, one resource, and one Collector.
Frequently Asked Questions
What endpoint format does the gRPC metric exporter expect?
The gRPC OTLPMetricExporter takes a host:port endpoint such as otel-collector:4317 without an http scheme. The HTTP exporter, by contrast, expects a full URL with the /v1/metrics path. Mixing the two formats is the most common cause of connection failures.
How do I enable TLS to the Collector?
Leave insecure unset or False and supply a certificate through the credentials argument or the OTEL_EXPORTER_OTLP_CERTIFICATE environment variable. Use insecure=True only on a trusted local network, since it disables transport encryption entirely.
What export interval should I choose?
Fifteen to sixty seconds suits most services. Shorter intervals raise network and Collector load without improving dashboards that aggregate over minutes, while very long intervals delay alerting and risk losing the final window if a process crashes.
What happens when the Collector is down during an export?
The gRPC exporter retries the failed batch with exponential backoff bounded by the export timeout, then drops that batch. The reader does not queue across cycles, so under delta temporality the dropped window is lost and under cumulative the next successful export re-states the running total.
Do I need a Collector, or can I export straight to a backend?
The same exporter can target any OTLP-capable backend directly, so a Collector is not strictly required. Running one is still recommended in production because it adds batching, retries, and a place to filter or reroute metrics without redeploying the application.