OpenTelemetry SDK Setup for Python

Implementing a production-grade observability pipeline begins with precise OpenTelemetry SDK configuration: the order in which you build the resource, create the provider, attach a processor, and register propagators determines whether traces arrive intact or fragment under load. This guide is part of the Distributed Tracing and OpenTelemetry in Python guide, and it details dependency management, provider initialization, and exporter routing for Python workloads. It feeds directly into framework integrations such as instrumenting Python web frameworks and the focused walkthrough for setting up OpenTelemetry in FastAPI, and it underpins the span lifecycle and attributes you record on top of it.

The fixed SDK bootstrap order Five steps run top to bottom: build the Resource, create the TracerProvider with an explicit sampler, attach the BatchSpanProcessor around the OTLP exporter, call set_tracer_provider, then register propagators and attach instrumentation. Brackets on the right note that the resource and sampler are fixed for the provider's lifetime, that the exporter behind the processor is swappable per environment, and that any tracer created before the global provider is set stays bound to the no-op provider. 1 · Resource service.name, version, environment 2 · TracerProvider + sampler ParentBased(TraceIdRatioBased) 3 · BatchSpanProcessor wraps the OTLP exporter 4 · set_tracer_provider promote to global state 5 · Propagators + instrumentation W3C trace context + baggage Fixed for the provider's lifetime — the resource and sampler never change later. Swappable: the same processor takes a console, in-memory, or OTLP exporter. Any get_tracer or instrument_app that runs before step 4 stays bound to the no-op provider — spans vanish silently.
The fixed initialization order — resource, provider, batch processor, global registration, then propagators and instrumentation — with what each step locks in.

Key implementation priorities are dependency isolation, global provider bootstrapping, semantic resource mapping, and OTLP exporter tuning. Get these four right and telemetry ingests reliably under high concurrency; get the order wrong and spans silently route to a no-op provider.

The most common failure in SDK setup is not a crash — it is silence. When initialization runs in the wrong order or the provider is never promoted to global, tracers return non-recording spans, the application behaves normally, and no error appears anywhere. Nothing reaches the collector, and the absence looks like a networking problem rather than a bootstrap bug. The discipline this guide enforces — a fixed order, an explicit resource, a batch processor, and a global registration that happens before any instrumentation attaches — exists specifically to make that silent failure impossible.

Prerequisites

Pin every OpenTelemetry package to a bounded range so a transitive upgrade cannot change instrumentation behavior mid-deploy. The API and SDK version together, while contrib instrumentation packages track a separate 0.x beta line.

# pyproject.toml — production pinning strategy
[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",
  "opentelemetry-semantic-conventions>=0.51b0,<1.0.0",
]
export OTEL_SERVICE_NAME="payment-service"
export OTEL_EXPORTER_OTLP_ENDPOINT="otel-collector:4317"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,team=platform"
export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
export OTEL_TRACES_SAMPLER_ARG="0.1"

The SDK reads these variables automatically, so they are the deployment-time source of truth that overrides the code defaults shown later. Set them in the orchestration manifest — a Kubernetes env block, an ECS task definition, a systemd unit — rather than baking values into the image, so the same artifact promotes cleanly from staging to production. Keep secrets out of OTEL_RESOURCE_ATTRIBUTES; it is replicated onto every span and is meant for low-cardinality routing dimensions, not credentials.

Two version lines move at different speeds The upper timeline shows opentelemetry-api and opentelemetry-sdk releasing together at 1.29.0, 1.30.0, 1.31.0 and 1.32.0, joined by vertical ties because the two must match. The lower timeline shows the contrib instrumentation and semantic-convention packages releasing far more often across the 0.50b0 to 0.53b0 beta range. A dashed span marks how an unpinned dependency jumps several beta releases while the core line has not moved. Core — opentelemetry-api + opentelemetry-sdk, stable 1.x the two must match; a mismatch raises ImportError at startup 1.29.0 1.30.0 1.31.0 1.32.0 api sdk Contrib — opentelemetry-instrumentation-*, semconv, beta 0.x advances on its own faster cadence; pin it to a separate bounded range 0.50b0 0.51b0 0.52b0 0.53b0 an unpinned pin resolves here — emitted attributes change, core 1.x unmoved
The core API and SDK release together on the stable 1.x line; contrib instrumentation moves independently on the 0.x beta line, which is why the two need separate bounded pins.

Concept and Architecture

The OpenTelemetry ecosystem strictly separates opentelemetry-api from opentelemetry-sdk. Libraries depend only on the API, which is a no-op until an application installs and configures the SDK. This decoupling lets you instrument a library without forcing a tracing runtime on its consumers, and it means your application — not your dependencies — owns the export pipeline.

Why the API and SDK version lines differ

This split has a direct consequence for version management. The opentelemetry-api and opentelemetry-sdk packages share a stable 1.x version line and must match, because the SDK implements the exact API surface the version declares; a mismatch raises ImportError at startup. The contrib instrumentation packages (opentelemetry-instrumentation-*) and the semantic-convention package track a separate 0.x beta line that advances faster, so pin them to their own bounded range rather than assuming they move in lockstep with the core. Keeping these two lines pinned independently is what prevents a routine dependency bump from silently changing which spans your libraries emit.

The four objects that make up the pipeline

Four SDK objects do the work. The Resource is an immutable bag of identity attributes attached to the TracerProvider. The TracerProvider is the global factory that hands out tracers and owns the processor chain. A SpanProcessor receives spans as they end and decides how to export them; the BatchSpanProcessor is the production choice because it queues and flushes on a background thread. The exporter — here OTLP over gRPC — serializes spans to Protobuf and ships them to the collector. Because the provider is global state, fragmenting it across local instances complicates downstream querying and breaks service-topology generation, which is why one provider per process is the rule. Proper dependency resolution and a clean provider lifecycle directly shape how you later manage the span lifecycle and attributes across rolling deployments.

These objects form a one-directional pipeline. A tracer obtained from the provider creates a span; when the span ends, the provider hands it to every registered processor in turn; the batch processor enqueues it and, on its schedule, drains the queue into the exporter, which serializes the batch and writes it to the collector. Nothing on this path runs on your request thread except the cheap enqueue, which is the whole point: the expensive work — serialization, the network round trip, retries — happens on the processor's background daemon thread. Understanding this flow explains every tuning parameter later, because each one controls a different stage of the same pipeline.

Where each stage of the export pipeline runs On the request thread a span starts, ends, and is enqueued in constant time with no network input or output. The batch processor queue straddles the thread boundary and is the only place the two lanes meet. On the background daemon thread the queue is drained up to 512 spans at a time, serialized to OTLP Protobuf, written over gRPC with a bounded timeout, and received by the collector, which owns retries and buffering. request thread · your code span starts start_as_current_span span ends attributes frozen enqueue O(1) — no network I/O thread boundary bounded queue max_queue_size 2048 batch processor · daemon thread drain batch up to 512 spans serialize OTLP Protobuf gRPC write timeout 10 s collector retries + buffering Nothing crosses back: the request thread never waits on serialization or the network.
The queue is the only point where the two threads meet — everything expensive happens after it, on the batch processor's daemon thread.

A useful mental model is that the resource answers "who am I", the provider answers "where do tracers come from", the processor answers "when and how do finished spans leave", and the exporter answers "in what format and to where". Each is replaceable in isolation, which is why the same application code runs unchanged across local development, CI, and production — only the processor and exporter pair differs between environments.

What is fixed at construction and what is swappable

The provider also owns the sampler. By default it samples every trace (ALWAYS_ON), which is fine in development but rarely what you want in production. Configure a ParentBased(TraceIdRatioBased(ratio)) sampler so the service honors an upstream sampling decision and only makes its own probabilistic choice for traces it roots — the trade-offs between head and tail decisions are covered in sampling strategies for distributed tracing. Set the sampler when you construct the provider, because — like the resource — it is fixed for the provider's lifetime. The OTLP exporter, by contrast, is the one piece you can swap freely: a console exporter for local debugging, an in-memory exporter for tests, and the gRPC OTLP exporter in production all plug into the same BatchSpanProcessor without other changes.

The same provider is also the join point for your other signals. The resource you define here is the one that should be attached to your metric pipeline, so that a service appears under one identity in traces and metrics alike — the pattern described in exporting OTLP metrics to the collector — and the active span ID is what makes adding trace IDs to log records worthwhile. A mismatched service.name between signals is the single most common reason a backend refuses to correlate them.

Step-by-Step Implementation

  1. Define the resource. Build it from environment-aware defaults using the official semantic-convention keys so a single codebase produces distinct identities per environment. Always include service.name, service.version, and deployment.environment. Using the ResourceAttributes constants instead of raw strings protects you from typos that would otherwise create silent duplicate dimensions — service.name and service_name are different keys to a backend, and only one will populate the service map. The resource you build here is merged with whatever OTEL_RESOURCE_ATTRIBUTES provides at runtime, so deployment manifests can add service.instance.id or cloud.region without a code change.
import os
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes

resource = Resource.create({
    ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "payment-service"),
    ResourceAttributes.SERVICE_VERSION: os.getenv("SERVICE_VERSION", "2.4.1"),
    ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("DEPLOYMENT_ENV", "production"),
})
  1. Create the provider with an explicit sampler. Instantiate exactly one TracerProvider with the resource and the sampler you intend to run in production. Doing this during module import is safe; defer expensive resource detection to a startup hook in containers to keep cold starts fast. Passing the sampler explicitly rather than relying on the ALWAYS_ON default means a service that forgets its environment variables under-samples predictably instead of flooding the collector.
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased

ratio = float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "0.1"))
provider = TracerProvider(
    resource=resource,
    sampler=ParentBased(root=TraceIdRatioBased(ratio)),  # honor upstream decisions
)
  1. Configure the OTLP exporter. Prefer gRPC in high-throughput services for its multiplexed connections and Protobuf framing; the HTTP/Protobuf exporter is the better choice only when a proxy or service mesh on the path cannot handle long-lived gRPC streams. Set a bounded timeout and keep insecure=False so a misconfigured TLS setting fails loudly instead of sending plaintext. The endpoint should point at a collector reachable on the local network — localhost for a sidecar, the node address for a daemonset, or an internal DNS name for a gateway pool — never directly at a public backend, because the collector is what provides the retries and buffering the exporter alone lacks.
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

exporter = OTLPSpanExporter(
    endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"),
    insecure=False,
    timeout=10,
)
  1. Attach the batch processor. Wrap the exporter in a BatchSpanProcessor with queue and batch sizes tuned to your concurrency, then register it on the provider. The processor flushes on a background daemon thread, so it is safe inside an asyncio event loop. The three knobs interact: max_queue_size caps memory and is the buffer that absorbs a brief collector outage, max_export_batch_size bounds the size of each OTLP request, and schedule_delay_millis caps how long a finished span waits before it is sent. Size the queue to roughly twice your peak concurrent spans so a traffic spike does not overflow it and start dropping spans, and keep the delay short enough that an unexpected crash costs only a few seconds of telemetry rather than a full batch interval.
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider.add_span_processor(BatchSpanProcessor(
    exporter,
    max_queue_size=2048,
    max_export_batch_size=512,
    schedule_delay_millis=5000,
))
  1. Set the global provider, then register propagators. Promote the provider to global state before any instrumentation attaches, and register a composite propagator so trace context and baggage both survive every hop. Routing always goes to a local collector first so buffering, retries, header injection, and sampling happen before data leaves your VPC — the same baseline the instrumenting Python web frameworks integrations build on. Order matters here as much as anywhere: set_tracer_provider must run before the first get_tracer call or any instrument_app invocation, because a tracer captured against the default provider stays bound to it for its lifetime even after you later set the real one.
from opentelemetry import trace
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator

trace.set_tracer_provider(provider)
set_global_textmap(CompositePropagator([
    TraceContextTextMapPropagator(),
    W3CBaggagePropagator(),
]))
  1. Drain the pipeline on shutdown. A batch processor that is still holding spans when the process exits loses them, which is why the last requests before a deploy are so often missing from the backend. Register a shutdown path that calls force_flush with a bounded timeout and then shutdown(), and make sure your orchestrator's termination grace period is longer than that timeout. In an ASGI application this belongs in the lifespan handler; in a worker process it belongs in the worker-exit hook.
import signal

def _drain(*_args) -> None:
    provider.force_flush(timeout_millis=5000)  # bounded; never block teardown forever
    provider.shutdown()                        # closes exporter connections

signal.signal(signal.SIGTERM, _drain)

Configuration Reference

Every value below can come from code or from the environment. The precedence is fixed: environment variables are read when the SDK object is constructed and override the code defaults you pass, which is what makes one image promotable across environments. The exception is anything you pass explicitly as a keyword argument to a constructor — that wins, so reserve explicit arguments for values that must never differ between deployments.

Parameter / env var Type Default Production-recommended
OTEL_SERVICE_NAME string unknown_service explicit service identifier per deployment
OTEL_EXPORTER_OTLP_ENDPOINT string localhost:4317 local collector address, e.g. otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL string grpc grpc; http/protobuf only behind a mesh that breaks streams
OTEL_RESOURCE_ATTRIBUTES string empty low-cardinality routing keys only, never secrets
OTEL_TRACES_SAMPLER string parentbased_always_on parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG float 1.0 0.01–0.2, raised for low-traffic services
max_queue_size int 2048 2× peak concurrent spans in flight
max_export_batch_size int 512 512; raise only if the collector keeps pace
schedule_delay_millis int 5000 2000–5000 to amortize network I/O
export_timeout_millis int 30000 10000 so a stalled export cannot hold the drain
OTLPSpanExporter.timeout int (s) 10 5–10 so retries cannot block indefinitely
OTLPSpanExporter.insecure bool False False in production; TLS to the collector
Which configuration layer wins Three bands rank the configuration sources for a single value. An explicit constructor argument always wins but is not set for the sampler in this example. The OTEL_TRACES_SAMPLER_ARG environment variable is set in the deployment manifest and wins. The SDK default of ratio 1.0 is overridden. A card on the right shows the resolved value of 0.05, taken from the environment. Resolving one value — the trace sampling ratio 1 · Explicit constructor argument always wins — reserve it for values that must never vary not set 2 · OTEL_ environment variable OTEL_TRACES_SAMPLER_ARG=0.05 from the manifest wins 3 · SDK default ratio 1.0 — every trace sampled overridden resolved at construction 0.05 from the environment
Precedence is fixed: an explicit keyword argument beats the environment, and the environment beats the SDK default — which is why one image can promote unchanged across deployments.

Two of these deserve a sizing rule rather than a fixed number. max_queue_size should be derived from measurement: multiply your peak requests per second by the average number of spans per request and by the drain interval in seconds, then double it for headroom. OTEL_TRACES_SAMPLER_ARG should be derived from budget: divide the number of traces per second your backend is provisioned for by your peak trace rate. Both change when traffic changes, which is exactly why they belong in the environment and not in code.

Async and Concurrency Considerations

Trace continuity across HTTP, gRPC, and async queues requires the propagators registered in step five; without W3CBaggagePropagator in the composite, baggage is silently dropped on every outbound call. The batch processor's background thread is event-loop-safe, but long-running workers still need disciplined span lifecycle management — an unclosed span pins its context and leaks memory across the worker's lifetime.

Use contextvars to keep span context isolated across concurrent tasks, and copy the context before scheduling work on an executor so child spans parent correctly rather than starting orphan roots. For non-instrumented libraries, wrap external calls in tracer.start_as_current_span() with explicit error handling so a raised exception still closes the span. The deeper mechanics of header injection, extraction, and baggage limits live in context propagation and baggage, and the task-level rules for gather, TaskGroup, and executors are collected in async tracing patterns.

Why the export thread does not compete with the event loop

The batch processor is safe for asyncio precisely because it never blocks the event loop: the loop only enqueues spans, and a separate OS thread performs the gRPC export. This does mean the export thread competes for the GIL, but the contention is negligible because serialization is fast and the network call releases the GIL while waiting. The one operation that does block is force_flush, which you should call only during graceful shutdown — never on the request path — to drain the queue before the process exits. In an ASGI application this belongs in the lifespan shutdown handler, exactly the pattern shown for setting up OpenTelemetry in FastAPI and reused across the instrumenting Python web frameworks integrations.

Fork-safe initialization under Gunicorn and uWSGI

Pre-fork servers are where SDK setup most often goes wrong, because the failure is structural rather than syntactic. If the provider is built at import time in the master process, every worker inherits the same exporter object, the same gRPC channel, and a reference to a background thread that does not survive fork(). The result is either exporter deadlock shortly after startup or spans that are queued and never drained. Build the provider inside the post-fork hook so each worker owns an independent pipeline.

# gunicorn.conf.py — one independent provider per worker
workers = 4

def post_fork(server, worker):
    from telemetry import configure_tracing   # import inside the hook, after fork
    configure_tracing(instance_id=f"worker-{worker.pid}")

def worker_exit(server, worker):
    from opentelemetry import trace
    provider = trace.get_tracer_provider()
    if hasattr(provider, "force_flush"):      # no-op provider has no flush
        provider.force_flush(timeout_millis=5000)
Provider at import time versus provider in post_fork On the left the master process builds the TracerProvider and its gRPC channel before forking, so all three workers inherit the same exporter and a background thread that did not survive the fork, ending in exporter deadlock or spans that are never sent. On the right the master creates no SDK objects and each worker builds its own provider, queue, and channel inside the post_fork hook, then flushes in worker_exit. Provider built at import time master process TracerProvider + gRPC channel worker 1 inherited channel worker 2 inherited channel worker 3 inherited channel One channel, one dead export thread deadlock, or spans queued and never sent Provider built in post_fork master process no SDK objects created worker 1 own provider queue + channel worker 2 own provider queue + channel worker 3 own provider queue + channel Each worker owns an independent pipeline worker_exit flushes before the process ends
The fork boundary decides everything: a provider built before it is shared and broken, a provider built after it gives every worker its own queue and connection.

The same rule applies to any process that forks after startup: Celery with the prefork pool, multiprocessing pools, and uWSGI with lazy apps disabled. Where the worker is a task consumer rather than a request handler, the extra concern is carrying trace context in the message body, which is covered in propagating trace context across Celery tasks.

A practical workflow is to develop against the console exporter (Example 3), then flip a single environment flag to route the same code through the OTLP exporter and a real collector. Because the only thing that changes between the two is the processor and exporter pair, your span names, attributes, and propagation behavior are identical in both, so what you verify locally is exactly what runs in production.

Production Code Examples

Example 1: Production SDK Initialization with OTLP Exporter

This assembles the full bootstrap — resource, provider, tuned batch processor, global registration — in the order the diagram prescribes.

import os
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
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" \
#   "opentelemetry-exporter-otlp-proto-grpc>=1.30.0,<2.0.0"

resource = Resource.create({                                  # 1. identity
    ResourceAttributes.SERVICE_NAME: os.getenv("SERVICE_NAME", "payment-service"),
    ResourceAttributes.SERVICE_VERSION: os.getenv("SERVICE_VERSION", "2.4.1"),
    ResourceAttributes.DEPLOYMENT_ENVIRONMENT: os.getenv("DEPLOYMENT_ENV", "production"),
})
provider = TracerProvider(resource=resource)                  # 2. provider
exporter = OTLPSpanExporter(                                   # 3. exporter
    endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4317"),
    insecure=False, timeout=10,
)
provider.add_span_processor(BatchSpanProcessor(               # 4. async batch export
    exporter, max_queue_size=2048, max_export_batch_size=512, schedule_delay_millis=5000,
))
trace.set_tracer_provider(provider)                           # 5. global, before instrumenting
tracer = trace.get_tracer(__name__)

with tracer.start_as_current_span("process_transaction") as span:
    span.set_attribute("payment.amount_cents", 4999)

Expected Output:

# Spans queue in memory and flush asynchronously to the OTLP endpoint.
# Representative payload received by the collector:
{
  "resourceSpans": [{
    "resource": {"attributes": [
      {"key": "service.name", "value": {"stringValue": "payment-service"}},
      {"key": "deployment.environment", "value": {"stringValue": "production"}}
    ]},
    "scopeSpans": [{"spans": [{
      "name": "process_transaction",
      "kind": "SPAN_KIND_INTERNAL",
      "status": {"code": "STATUS_CODE_OK"}
    }]}]
  }]
}

Example 2: Async-Compatible Context Propagation Setup

This registers W3C-compliant propagators globally so trace and baggage headers survive context switches in asyncio and concurrent.futures.

from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator

# Combine trace context and baggage, then register before instrumentation attaches.
set_global_textmap(CompositePropagator([
    TraceContextTextMapPropagator(),
    W3CBaggagePropagator(),
]))

Expected Output:

# Headers the SDK now injects on outbound HTTP/gRPC requests:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
baggage: user_id=usr_98765,tenant_id=acme_corp

Example 3: Local Verification with the Console Exporter

Before pointing at a collector, confirm the pipeline emits spans at all by swapping in the console exporter. This is the fastest way to prove your bootstrap order is correct, because a misconfigured provider prints nothing.

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
from opentelemetry.sdk.resources import Resource
# pip install "opentelemetry-sdk>=1.30.0,<2.0.0"

# SimpleSpanProcessor is fine here: this is a local debugging path, not production.
provider = TracerProvider(resource=Resource.create({"service.name": "bootstrap-check"}))
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)

with trace.get_tracer(__name__).start_as_current_span("smoke-test") as span:
    span.set_attribute("check.ok", True)

Expected Output:

{
  "name": "smoke-test",
  "context": {"trace_id": "0x0af7651916cd43dd8448eb211c80319c", "span_id": "0xb7ad6b7169203331"},
  "kind": "SpanKind.INTERNAL",
  "attributes": {"check.ok": true},
  "resource": {"attributes": {"service.name": "bootstrap-check"}},
  "status": {"status_code": "UNSET"}
}

Seeing this JSON on stdout proves the resource, provider, and tracer are wired correctly. Once it appears, switch the processor to BatchSpanProcessor and the exporter to OTLP for production, leaving everything else unchanged.

One bootstrap, three processor and exporter pairs The application code, the Resource carrying service.name, and the TracerProvider with its sampler are identical everywhere. Only the processor and exporter pair changes: SimpleSpanProcessor with ConsoleSpanExporter in local development, SimpleSpanProcessor with InMemorySpanExporter in automated tests, and BatchSpanProcessor with OTLPSpanExporter in production. One bootstrap unchanged everywhere application code Resource · service.name TracerProvider + sampler local development SimpleSpanProcessor + ConsoleSpanExporter printed to stdout automated tests SimpleSpanProcessor + InMemorySpanExporter asserted in pytest production BatchSpanProcessor + OTLPSpanExporter batched to the collector
Only the processor and exporter pair differs between environments, so span names, attributes, and propagation behave identically in all three.

Example 4: Asserting on Spans in Tests

The in-memory exporter turns the SDK into a test double, so bootstrap regressions fail in CI rather than in production. Build a fresh provider per test and assert against the exported spans directly; never reuse the global provider between tests, because it keeps every processor registered from earlier cases.

import pytest
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" "pytest>=8.0.0,<9.0.0"

@pytest.fixture
def spans():
    exporter = InMemorySpanExporter()
    provider = TracerProvider()                       # local, never set as global
    provider.add_span_processor(SimpleSpanProcessor(exporter))
    yield provider.get_tracer("test"), exporter
    provider.shutdown()

def test_charge_records_amount(spans):
    tracer, exporter = spans
    with tracer.start_as_current_span("charge") as span:
        span.set_attribute("payment.amount_cents", 4999)
    finished = exporter.get_finished_spans()
    assert [s.name for s in finished] == ["charge"]
    assert finished[0].attributes["payment.amount_cents"] == 4999

Expected Output:

collected 1 item
tests/test_tracing.py .                                              [100%]
1 passed in 0.06s

Common Mistakes

Spans Vanish Because Instrumentation Ran First

Error signature: No spans reach the collector despite a healthy exporter; tracers return non-recording spans. Root cause: Auto-instrumentation or get_tracer ran before set_tracer_provider, so tracers bound to the default no-op provider. Remediation: Bootstrap the SDK at the very top of process startup and call set_tracer_provider before importing or attaching any framework instrumentation.

Triage tree for missing spans Start from no spans in the backend and ask whether the console exporter prints locally. If it does not, ask whether set_tracer_provider ran before the first get_tracer call: if not, bootstrap first because tracers are bound to the no-op provider; if it did, the sampler dropped the trace. If the console exporter does print, ask whether the collector endpoint resolves and its TLS matches: if not, fix the endpoint or TLS and read the exporter logs for timeouts; if it does, spans are queued but never drained, so add force_flush and shutdown on SIGTERM. no spans in the backend Does the console exporter print spans locally? no yes Did set_tracer_provider run before the first get_tracer? no yes Does the collector endpoint resolve, and does TLS match? no yes bootstrap the SDK first — tracers bound to the no-op provider the sampler dropped it check SAMPLER_ARG and the parent decision fix the endpoint or TLS exporter logs show repeated export timeouts queued, never drained force_flush and shutdown on SIGTERM or lifespan
Four questions separate the four causes of missing spans: bootstrap order, sampling, transport, and an undrained queue at shutdown.

Event Loop Stalls Under Load

Error signature: Request latency spikes and SpanExportError: Export timed out during peak traffic. Root cause: A SimpleSpanProcessor is exporting synchronously on every span end, blocking the event loop on network I/O. Remediation: Replace it with BatchSpanProcessor, size max_queue_size to roughly twice peak concurrency, and keep schedule_delay_millis between 2000 and 5000.

Service Map Shows unknown_service

Error signature: Traces aggregate under unknown_service or the executable name; topology generation fails. Root cause: No Resource with service.name was attached before constructing the provider. Remediation: Build the Resource explicitly and set OTEL_SERVICE_NAME in deployment manifests so both code and environment agree.

Baggage Silently Disappears Across Services

Error signature: traceparent propagates correctly but custom baggage keys never reach downstream services. Root cause: A custom propagator was registered without including W3CBaggagePropagator. Remediation: Always register baggage inside a CompositePropagator alongside the trace-context propagator.

Global Provider Contamination in Tests

Error signature: Duplicate spans or cross-test telemetry bleed between test runs. Root cause: The global provider persists across test cases and worker restarts. Remediation: Build a local provider with an in-memory exporter per test, as in Example 4, and initialize providers only after worker forking in multi-process servers.

Duplicate Spans After a Fork

Error signature: Every request appears twice in the backend, or the exporter deadlocks shortly after startup under Gunicorn. Root cause: The provider and its exporter connection were created in the master process and inherited by forked workers, so multiple workers share one background thread and gRPC channel. Remediation: Build the provider inside a post-fork hook (Gunicorn's post_fork, or a per-worker startup callback) so each worker owns an independent exporter connection and batch queue.

Frequently Asked Questions

How do I handle SDK initialization in a multi-process worker environment?

Initialize the provider after the worker process forks, using a post-fork or per-worker startup hook. This gives each process its own exporter connections and batch buffers and avoids sharing file descriptors across the fork.

What is the performance impact of synchronous versus asynchronous exporters?

Synchronous export blocks the calling thread on network I/O for every span, adding latency to each request. The batch processor flushes on a background daemon thread, which removes export from the request path and is the only safe choice for production.

Can I mix auto-instrumentation with manual SDK setup?

Yes, but your manual provider must be set as the global provider before auto-instrumentation attaches, otherwise spans go to the no-op default. Disable overlapping framework instrumentations to avoid duplicate spans.

How do I configure fallback behavior when the collector is unreachable?

Set a bounded exporter timeout so retries cannot pile up, rely on the batch queue to absorb short outages, and let spans drop once the queue fills rather than blocking the application. Alert on sustained export failures from the collector's own health metrics.

Should I configure the SDK in code or through environment variables?

Use both, deliberately. Express structure in code — which processors, which propagators, which sampler type — and leave values that change per deployment, such as the endpoint, service name, and sampling ratio, to environment variables. Environment variables win over code defaults, so the same container image promotes from staging to production unchanged.