Configuring the OTLP Span Exporter in Python
Most "OpenTelemetry is not working" investigations end at one of four things: the wrong exporter package, the wrong endpoint variable, a TLS mismatch, or a timeout that hides the real error. This page covers each of them, and the diagnostics that turn a silent failure into a status code. It builds on exporters and the OpenTelemetry Collector, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
# gRPC on 4317
pip install "opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"
# or HTTP/protobuf on 4318
pip install "opentelemetry-exporter-otlp-proto-http>=1.27.0,<2.0.0"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_INSECURE=true
export OTEL_EXPORTER_OTLP_TIMEOUT=10000
The two exporter packages are separate distributions with separate import paths. OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf with only the gRPC package installed raises at startup rather than falling back.
Implementation
Step 1 — Construct the exporter from the environment. With the variables set, the constructor takes no arguments at all — which is the configuration you want, because it moves every endpoint decision out of the image.
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
exporter = OTLPSpanExporter() # endpoint, protocol, timeout, headers: all from env
Explicit arguments override the environment, which makes them useful for tests and harmful in production images:
exporter = OTLPSpanExporter(
endpoint="http://otel-collector:4317",
insecure=True, # plaintext to a local Collector
timeout=10, # seconds, not milliseconds, in the constructor
)
Note the unit mismatch: OTEL_EXPORTER_OTLP_TIMEOUT is milliseconds, the constructor's timeout is seconds. Passing 10000 to the constructor sets a timeout of nearly three hours.
Step 2 — Choose the transport and set the matching endpoint.
# gRPC — the path is part of the protocol, so the endpoint is host:port
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
# HTTP/protobuf — the SDK appends /v1/traces to this base
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
gRPC is more efficient over a persistent connection and is the default. HTTP/protobuf survives proxies, meshes and load balancers that mishandle HTTP/2 trailers. Neither is more correct; pick the one your network is friendly to, and be prepared to switch.
Step 3 — Keep TLS and credentials at the Collector. A sidecar or node-local Collector is one hop that never leaves the host, so plaintext is appropriate and keeps certificate rotation out of the application.
exporter = OTLPSpanExporter(insecure=True) # to a local Collector
When a service genuinely must reach a remote endpoint directly, configure TLS explicitly rather than disabling verification:
export OTEL_EXPORTER_OTLP_CERTIFICATE=/etc/ssl/certs/collector-ca.pem
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer ${INGEST_TOKEN}"
OTEL_EXPORTER_OTLP_HEADERS is comma-separated key=value pairs, and it is where a vendor token ends up in every service's environment — which is the argument for the Collector, restated as an operational cost.
Step 4 — Fail fast. Lower the export timeout so a stalled backend cannot hold the exporter while the queue fills.
export OTEL_EXPORTER_OTLP_TIMEOUT=10000 # ms — down from the 30 s default
export OTEL_BSP_EXPORT_TIMEOUT=10000 # the processor's own budget
Step 5 — Turn on diagnostics while proving the path. The SDK logs through the opentelemetry logger namespace, which most configurations leave at WARNING or suppress entirely.
import logging
logging.getLogger("opentelemetry").setLevel(logging.DEBUG)
export OTEL_PYTHON_LOG_CORRELATION=true # unrelated, but usually wanted alongside
Configuration options
| Option | Env var | Constructor | Default | Recommended |
|---|---|---|---|---|
| Endpoint (base) | OTEL_EXPORTER_OTLP_ENDPOINT |
endpoint= |
localhost:4317 |
the local Collector |
| Endpoint (signal) | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
— | unset | leave unset unless the path differs |
| Protocol | OTEL_EXPORTER_OTLP_PROTOCOL |
— | grpc |
match the installed package |
| Insecure | OTEL_EXPORTER_OTLP_INSECURE |
insecure= |
false |
true to a local Collector |
| Certificate | OTEL_EXPORTER_OTLP_CERTIFICATE |
credentials= |
system store | only for direct remote export |
| Headers | OTEL_EXPORTER_OTLP_HEADERS |
headers= |
none | none — keep tokens in the Collector |
| Timeout | OTEL_EXPORTER_OTLP_TIMEOUT (ms) |
timeout= (s) |
10 s | 10 s — mind the unit difference |
| Compression | OTEL_EXPORTER_OTLP_COMPRESSION |
compression= |
none | gzip for a remote endpoint |
Verification
Start a Collector with a debug exporter, send one span, and read both ends.
docker run --rm -p 4317:4317 -p 4318:4318 \
-v "$PWD/otel-collector.yaml:/etc/otelcol/config.yaml" \
otel/opentelemetry-collector:latest
import logging
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
logging.basicConfig(level=logging.DEBUG)
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(insecure=True)))
trace.set_tracer_provider(provider)
with trace.get_tracer("probe").start_as_current_span("export-check") as span:
span.set_attribute("probe", True)
provider.force_flush() # do not wait for the schedule delay
provider.shutdown()
Expected Output (Collector side):
Trace ID : 4bf92f3577b34da6a3ce929d0e0e4736
Span ID : 00f067aa0ba902b7
Name : export-check
Kind : Internal
Attributes : probe: true
The force_flush() matters in a probe script: without it the process exits before the five-second schedule delay elapses and nothing is sent, which reads exactly like a broken endpoint.
Failure looks like this, and is the reason to enable the SDK logger:
Expected Output (failure):
DEBUG opentelemetry.exporter.otlp.proto.grpc.exporter Waiting 1s before retrying export of span
DEBUG opentelemetry.exporter.otlp.proto.grpc.exporter Waiting 2s before retrying export of span
WARNING opentelemetry.exporter.otlp.proto.grpc.exporter Transient error StatusCode.UNAVAILABLE encountered while exporting span batch, retrying in 4s
UNAVAILABLE is a connection problem — wrong port, Collector down, network policy. UNIMPLEMENTED means you reached something that is not an OTLP receiver. A 404 on HTTP means the path is wrong.
Common mistakes
Spans stop when switching from gRPC to HTTP
Error signature: everything worked on 4317; on 4318 the backend receives nothing and the service logs nothing.
Root cause: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT was set to a bare host and port, which gRPC ignores and HTTP turns into a request for the root path.
Remediation: use the base OTEL_EXPORTER_OTLP_ENDPOINT and let the SDK append /v1/traces.
The timeout is three hours
Error signature: a hung export never times out and the queue fills.
Root cause: the constructor's timeout is in seconds and was given a millisecond value.
Remediation: set the timeout through the environment variable, which is unambiguously milliseconds, or pass seconds to the constructor.
ImportError at startup after changing the protocol
Error signature: ModuleNotFoundError: opentelemetry.exporter.otlp.proto.http.
Root cause: the protocol variable changed but the matching exporter package is not installed.
Remediation: install the package for the transport you are using, or the opentelemetry-exporter-otlp meta-package that includes both.
Configuration precedence, and why it matters
Four layers can set the same value, and knowing the order saves the specific confusion of a setting that is present in the deployment and demonstrably not in effect.
A constructor argument wins over everything. Below it, the signal-specific environment variable — OTEL_EXPORTER_OTLP_TRACES_ENDPOINT — wins over the general one. Below that, the general variable, OTEL_EXPORTER_OTLP_ENDPOINT. And below everything, the SDK's default of localhost:4317.
The practical consequence is that a hard-coded constructor argument silently overrides the environment, which is fine in a test and a problem in an image that ships to three environments. A useful rule is that production code passes nothing to the constructor at all: every value comes from the environment, so the deployment manifest is the single description of where telemetry goes.
# in tests, or a script
exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
# in the service
exporter = OTLPSpanExporter() # everything from the environment
| Layer | Example | Wins over |
|---|---|---|
| Constructor argument | OTLPSpanExporter(endpoint=…) |
everything |
| Signal-specific env | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
the general env var |
| General env | OTEL_EXPORTER_OTLP_ENDPOINT |
the default |
| SDK default | localhost:4317 |
— |
Compression, and when it pays
OTEL_EXPORTER_OTLP_COMPRESSION=gzip reduces payload size substantially — span batches are repetitive protobuf and compress well — at the cost of CPU on the exporter thread. To a Collector on the same host that trade is usually not worth making: the network is a loopback interface and the CPU is real. To a remote endpoint across a network you pay for, it usually is.
That is the same reasoning that puts the Collector next to the service in the first place: the application's hop should be cheap and local, and the expensive, compressed, authenticated hop should happen once from the Collector rather than once per service replica.
Running two exporters
Occasionally a migration needs spans in two places at once — an incumbent backend and a candidate. Two BatchSpanProcessor instances on the same provider does this, each with its own exporter and its own queue.
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=INCUMBENT)))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=CANDIDATE)))
Both processors see every span, so the cost is two serialisations and two queues, and a failure in one does not affect the other. It works and it is the wrong place to do it in most cases: a Collector with two exporters achieves the same fan-out with one hop from the application, one serialisation, and a change that does not require a redeploy to reverse. Keep the two-processor form for the case where the Collector itself is what you are evaluating.
The diagnostics worth leaving on
The SDK's internal logger is noisy at DEBUG and useful at WARNING, which is a reasonable permanent setting: it stays quiet while exports succeed and produces a record naming the status when they do not. That single line is what turns "no traces" from a search into a diagnosis, and it costs nothing while everything is working.
logging.getLogger("opentelemetry").setLevel(logging.WARNING)
Related
- Exporters and the OpenTelemetry Collector — the parent guide: the whole export path and where each responsibility belongs.
- Tuning BatchSpanProcessor for throughput — the queue in front of this exporter.
- Running the OpenTelemetry Collector for Python services — what to point the endpoint at.
- OpenTelemetry SDK setup — the provider that owns this exporter.
- Exporting OTLP metrics to the collector — the same transport decisions for the metrics signal.
Frequently Asked Questions
Which OTLP exporter package do I install?
opentelemetry-exporter-otlp-proto-grpc for gRPC on port 4317, or opentelemetry-exporter-otlp-proto-http for HTTP protobuf on 4318. There is also an opentelemetry-exporter-otlp meta-package that pulls in both. They are separate import paths, so the OTEL_EXPORTER_OTLP_PROTOCOL variable only takes effect if the corresponding package is installed — otherwise you get an ImportError at startup.
Why does my HTTP exporter return 404?
Because of the two endpoint variables. OTEL_EXPORTER_OTLP_ENDPOINT is a base URL and the SDK appends /v1/traces to it. OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is the complete URL and nothing is appended. Setting the second one to a base like http://collector:4318 sends traces to the root path, which every Collector answers with 404.
Do I need TLS between my service and the Collector?
Not when the Collector is a sidecar or a node-local agent — that traffic never leaves the host or the pod, and plaintext keeps the service free of certificate management. TLS belongs on the hop from the Collector to the backend, where it crosses a network you do not control, and that is a Collector configuration rather than an application one.
How does the exporter retry?
Per batch, with exponential backoff, for a bounded period governed by the export timeout. It retries on transient conditions — unavailable, deadline exceeded, resource exhausted — and gives up immediately on permanent ones such as an authentication failure, since retrying those cannot succeed. When it gives up, the batch is dropped and counted; nothing is written to disk.
Can I see why exports are failing?
Yes — the SDK logs through Python's logging module under the opentelemetry namespace, and those records are suppressed by default in most configurations. Set that logger to DEBUG while you are proving the path works, and you get the status code and message for each failed export instead of silence.