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.

OTLP metric export path Inside the Python process, instruments feed a periodic reader on a fifteen second cadence and an OTLP gRPC exporter. The exporter sends over gRPC on port 4317 with TLS to the Collector, whose OTLP receiver passes metrics through a batch processor to the metrics pipeline exporter, which writes to the backend store. Python process Counters · Histograms Periodic reader · 15 s OTLP gRPC exporter OpenTelemetry Collector OTLP receiver · :4317 batch processor metrics pipeline exporter Backend store & query OTLP gRPC :4317 TLS metrics every payload carries the resource — service.name travels with the data points
The export hop: reader and exporter in-process, OTLP receiver and pipeline in the Collector.

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.

Delta versus cumulative temporality across four export cycles The same counter records three, five, four and four in four consecutive fifteen second windows. The delta payloads carry 3, 5, 4 and 4; the cumulative payloads carry the running totals 3, 8, 12 and 16. The third export fails because the Collector is unreachable. Under delta the dropped window is lost for good and the backend stays four short; under cumulative the next successful export re-states the total of sixteen and the gap heals. cycle 1 cycle 2 cycle 3 cycle 4 t+15 s t+30 s t+45 s · fails t+60 s recorded this window +3 +5 +4 +4 delta per-window increment 3 exported 5 exported 4 dropped 4 exported gone for good — the backend stays 4 short cumulative running total 3 exported 8 exported 12 dropped 16 exported the next export re-states the total — the gap heals
One counter, two payload shapes: a dropped cycle costs delta a whole window, while cumulative repairs itself on the next export.

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.

One export cycle: collect, retry, succeed, idle Within a fifteen second interval the reader collects for the first one and a half seconds, then the exporter attempts a send that fails with UNAVAILABLE, backs off one second, fails again, backs off two seconds, and succeeds on the third attempt at about eight seconds. The ten second export timeout is drawn as a dashed deadline, leaving roughly two seconds of unused retry budget and five seconds of headroom before the next collection begins. export_timeout_millis · 10 s retry with exponential backoff, all inside one cycle collect try 1 1 s try 2 2 s backoff try 3 · ok spare headroom before the next collect 0 s 5 s 10 s 15 s attempts 1 and 2 return StatusCode.UNAVAILABLE, so the exporter waits and resends if the deadline arrives first the batch is abandoned — the reader never re-queues it
Inside one 15 s cycle: the timeout bounds the retries, and what is left over is headroom, not queue.

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.

Which layer sets the endpoint Four stacked layers can supply the same setting. The SDK default localhost:4317 sits lowest, then the generic OTEL_EXPORTER_OTLP_ENDPOINT variable, then the signal-specific OTEL_EXPORTER_OTLP_METRICS_ENDPOINT variable, and finally the explicit constructor argument, which is the value the exporter actually uses. The three lower layers are marked overridden. lowest precedence 1 SDK default endpoint = localhost:4317 overridden 2 generic environment variable OTEL_EXPORTER_OTLP_ENDPOINT overridden 3 signal-specific variable OTEL_EXPORTER_OTLP_METRICS_ENDPOINT overridden 4 constructor argument OTLPMetricExporter(endpoint=…) in effect what the SDK uses each layer overrides the one above it, and a signal-specific METRICS_ variable beats the generic one
Four layers can set one option; the lowest band on this stack is the value that actually ships.

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.

Three checks and the evidence each one produces Lane one waits a full interval and expects a Collector debug line reporting data points with the configured aggregation temporality. Lane two points the exporter at a closed port and expects an UNAVAILABLE retry warning while the process keeps serving traffic. Lane three sends SIGTERM and expects a force_flush line reporting the final metrics before a clean exit. the check what proves it 1 · steady state wait one interval metrics: 2 · data points: 2 · resource metrics: 1 AggregationTemporality matches what you configured 2 · Collector down aim at a closed port WARNING transient StatusCode.UNAVAILABLE, retrying the process keeps serving traffic — no crash, no restart 3 · SIGTERM stop the process force_flush completed in 41ms; 2 metrics exported the trailing edge of the dashboard stays flat
Each check is only finished when its evidence appears: a data-point count, a retry warning, and a final flush.

Common mistakes

  • Error signature: StatusCode.UNAVAILABLE on 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 on 4318. Remediation: use a bare host:port for the gRPC exporter and confirm the receiver's protocols.grpc block 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 (or OTEL_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() and shutdown() from a SIGTERM handler, 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_millis was set equal to or larger than export_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 MeterProvider was built before fork(), so the reader thread exists only in the master process. Remediation: construct the provider in the post_fork hook so each worker starts its own reader, and add a worker-identifying resource attribute if the series must be distinguishable.

Reading the symptom back to the cause Start from metrics never reaching the backend and branch on what the logs show. UNAVAILABLE forever means the endpoint carried a scheme or the Collector is HTTP only, so use a bare host and port on 4317. No exporter log at all means the provider was built before fork, so build it in post_fork. Payloads that arrive with wrong numbers mean the temporality disagrees with the backend, so match the backend. Gaps only after a deploy mean the process exits before the next interval, so flush on SIGTERM. metrics never reach the backend the exporter logs UNAVAILABLE forever the exporter logs nothing at all payloads arrive, numbers look wrong gaps appear only after each deploy endpoint had a scheme or the port is HTTP use host:port on 4317 the provider was built before fork() build it in post_fork temporality disagrees with the backend align the preference the process exits before the next interval flush on SIGTERM
Each symptom has one dominant cause: read the branch that matches your logs, then apply the fix under it.

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.