Propagating Context Across Thread and Process Pools
OpenTelemetry keeps the current span in a context variable, and context variables are per thread and per task. Work submitted to a ThreadPoolExecutor runs on a worker thread whose context is empty, so any span it starts has no parent and becomes the root of an unrelated trace. Work sent to a process pool is further away still: the context object cannot cross the process boundary at all. This page covers carrying context into both. It is a task article under context propagation and baggage, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
pip install "opentelemetry-api>=1.27.0,<2.0.0" \
"opentelemetry-sdk>=1.27.0,<2.0.0"
Implementation
Step 1 — Capture the context where the span is current. The submitting thread is the only place the request's span is the current span. context.get_current() returns an immutable snapshot of the whole context — span, baggage and anything else stored there — which can be carried to another thread safely.
from opentelemetry import context, trace
tracer = trace.get_tracer("reports")
def submit_with_context(pool, fn, *args, **kwargs):
ctx = context.get_current() # 1. captured on the caller's thread
return pool.submit(_run_in_context, ctx, fn, *args, **kwargs)
Step 2 — Attach inside the worker, detach afterwards. context.attach makes the captured context current on the worker thread and returns a token; context.detach restores what was there before. Detaching in a finally block matters because pool threads are reused: a context left attached would become the parent of whatever the thread runs next.
def _run_in_context(ctx, fn, *args, **kwargs):
token = context.attach(ctx) # 2. the request's span is now current
try:
return fn(*args, **kwargs)
finally:
context.detach(token) # 3. clean for the next task
Step 3 — Prefer asyncio.to_thread from async code. asyncio.to_thread copies the current context into the call automatically, so no wrapper is needed. loop.run_in_executor does not, which is a frequent source of orphan spans in async services that offload blocking calls.
import asyncio
async def build_report(ids):
with tracer.start_as_current_span("build report"):
# context carried automatically
rows = await asyncio.to_thread(load_rows, ids)
return render(rows)
Step 4 — Serialise context for process pools. A process has its own memory, and context objects cannot be pickled. The propagator used for HTTP headers works equally well here: inject the context into a dictionary, send the dictionary with the work, and extract it in the worker. The worker's span then has the right trace identifier and parent.
from concurrent.futures import ProcessPoolExecutor
from opentelemetry.propagate import inject, extract
def submit_to_process(pool: ProcessPoolExecutor, fn, *args):
carrier: dict[str, str] = {}
inject(carrier) # traceparent (+ baggage) as strings
return pool.submit(_process_entry, carrier, fn, *args)
def _process_entry(carrier, fn, *args):
ctx = extract(carrier)
with tracer.start_as_current_span(fn.__name__, context=ctx):
return fn(*args)
The worker process also needs its own tracer provider, initialised in the pool's initialiser, because providers do not travel across a spawn and a forked provider's export thread does not survive the fork.
Step 5 — Wrap the executor once. Relying on every call site to use the helper fails the first time someone writes pool.submit directly. An executor subclass that propagates context on every submission makes it the default.
from concurrent.futures import ThreadPoolExecutor
class ContextThreadPoolExecutor(ThreadPoolExecutor):
def submit(self, fn, /, *args, **kwargs):
ctx = context.get_current()
return super().submit(_run_in_context, ctx, fn, *args, **kwargs)
Expected Output: worker spans sharing the request's trace identifier, parented to its span.
trace 9f2a71c4… GET /report span 4b1e77a2 parent -
trace 9f2a71c4… fetch A span 7c30a1d9 parent 4b1e77a2
trace 9f2a71c4… fetch B span 1e84b6f0 parent 4b1e77a2
trace 9f2a71c4… fetch C span a9d2c741 parent 4b1e77a2
Child or link
Once context reaches the worker, there is a choice about how the worker's span relates to the originating one, and it is worth making deliberately.
A child span says the work is part of the originating operation, and the operation's duration includes it. This is right for fan-out within a request — the report handler waiting on three fetches — where the parent genuinely cannot finish until the children do, and the trace's timeline should show that.
A link says the work is related to the originating operation without being inside it. This is right when the work outlives the request, when one piece of work serves many requests, or when the work is batched: a background flush triggered by a request, a worker processing a batch of items submitted by many different requests. Making such work a child produces traces whose root finishes long before its children, or a single batch span with a hundred parents, neither of which a trace viewer renders sensibly. The mechanics of links are covered in adding span links for batch work.
The deciding question is whether the originating operation waits for the work. If it does, child; if it does not, link.
What else travels in the context
The context object carries more than the current span, which is part of why propagating it whole is better than passing a trace identifier by hand.
Baggage — key-value pairs such as a tenant or a feature flag, described in using baggage for tenant and feature context — lives in the context. A worker that receives the whole context sees the baggage too, so tenant-aware sampling and attribute enrichment work unchanged inside the pool.
Suppression flags that instrumentation uses to avoid recording its own internal calls live there as well. Carrying the whole context keeps that behaviour consistent in the worker.
Anything else stored in context variables — structlog's bound fields, a request deadline — is not part of the OpenTelemetry context and needs its own propagation, or a contextvars.copy_context().run wrapper that copies all context variables at once. For thread pools, running the task inside a copied contextvars context carries everything, including the OpenTelemetry context, in one step.
import contextvars
def submit_all_context(pool, fn, *args):
ctx = contextvars.copy_context() # every context variable, not just OTel's
return pool.submit(ctx.run, fn, *args)
Finding orphans in an existing service
Services that have been running for a while often have orphan spans already, and they are easy to find once you know what to look for.
The signature is a root span whose name is not an entry point. A service's legitimate roots are server spans for incoming requests, consumer spans for messages, and spans for scheduled jobs. A root named after an internal function — fetch_prices, render_pdf, load_rows — almost always started on a thread where context was not propagated. A query over the trace store for root spans grouped by name, filtered to exclude the known entry points, lists them directly, usually with an obvious culprit per name.
The second signature is a parent span with an unexplained gap: a request that takes eight hundred milliseconds with children accounting for fifty. The missing time is frequently spent in pooled work whose spans became orphans. Comparing the gap's timing with the start times of the orphan roots from the first query often matches them up exactly.
Fixing each one is a matter of finding the submission site and using the propagating executor, and replacing the service's executors with the subclass above fixes all of them at once. Adding a test that fails when any root span has a non-entry-point name keeps them from coming back — a cheap guard that belongs alongside the checks in testing spans with the in-memory span exporter.
Configuration options
| Situation | Mechanism | Note |
|---|---|---|
| Async code offloading a call | asyncio.to_thread |
copies context automatically |
run_in_executor |
wrap with captured context | does not copy by default |
ThreadPoolExecutor |
attach/detach wrapper or subclass | detach in finally |
| All context variables | contextvars.copy_context().run |
includes structlog fields |
ProcessPoolExecutor |
inject into a dict, extract in worker | objects cannot be pickled |
| Worker provider | initialise in the pool initialiser | not inherited under spawn |
| Relationship | child if awaited, link if not | keeps traces readable |
Verification
Assert in a test that pooled spans share the parent's trace.
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
def test_pool_spans_join_the_request_trace(tracer, exporter: InMemorySpanExporter):
with ContextThreadPoolExecutor(4) as pool, tracer.start_as_current_span("request") as root:
futures = [pool.submit(lambda: tracer.start_span("work").end()) for _ in range(3)]
[f.result() for f in futures]
spans = exporter.get_finished_spans()
work = [s for s in spans if s.name == "work"]
assert all(s.context.trace_id == root.get_span_context().trace_id for s in work)
assert all(s.parent.span_id == root.get_span_context().span_id for s in work)
Expected Output:
.
1 passed
Common mistakes
Plain submit. Error signature: worker spans as separate root traces. Root cause: the worker thread's context is empty. Remediation: capture and attach, or use the subclass.
run_in_executor assumed to propagate. Error signature: orphan spans from offloaded blocking calls in async code. Root cause: it does not copy context. Remediation: asyncio.to_thread, or wrap the call.
No detach. Error signature: spans appearing in the wrong request's trace, intermittently. Root cause: a pool thread keeping an old context. Remediation: detach in finally.
Passing a Context to a process. Error signature: a pickling error, or a trace identifier passed by hand with no parent span. Root cause: context objects cannot cross processes. Remediation: inject into a carrier and extract.
Children for work that outlives the request. Error signature: traces whose root ends long before its children. Root cause: background work modelled as part of the request. Remediation: use a link.
Frequently Asked Questions
Why do spans from my thread pool appear as separate traces?
Because the worker thread has its own context, which is empty. ThreadPoolExecutor.submit does not copy the caller's context, so a span started in the worker has no parent and becomes the root of a new trace.
Does asyncio.to_thread propagate context?
Yes. It copies the current context into the call it runs on the thread, so spans and structlog bound fields are present. loop.run_in_executor does not do this by default, which is a common source of confusion.
Can I pass a Context object to a process pool?
No. Context objects are not picklable and would be meaningless in another process anyway. Inject the context into a dictionary with the configured propagator, send the dictionary, and extract it in the worker.
Should the worker's span be a child or a link?
A child when the work is part of the request and the request waits for it. A link when the work is batched or outlives the request, so it is related to the originating trace without pretending to be inside it.