n4nAI

OpenTelemetry context propagation for async LLM calls

Learn how to implement OpenTelemetry context propagation async patterns for LLM apps, keeping traces intact across asyncio tasks and threads.

n4n Team3 min read666 words

Audio narration

Coming soon — every post will get a voice note here.

Distributed tracing falls apart the moment your LLM call leaves the synchronous request path. Proper OpenTelemetry context propagation async code requires explicit handling of context across asyncio tasks, background workers, and callback chains, or your spans silently orphan and latency attribution becomes guesswork. This guide walks through a concrete Python asyncio + httpx + OpenTelemetry setup that keeps traces continuous from inbound HTTP request to streaming LLM response.

Step 1: Install and initialize the OpenTelemetry SDK

Start with the OTLP exporter and a tracer provider. Skip the all-in-one auto-instrumentation packages if you need precise control over async boundaries—they often miss cross-task spans.

pip install opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-api httpx

Initialize once at process start, before any request handling:

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

resource = Resource.create({"service.name": "llm-orchestrator"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("llm-orchestrator")

The tracer is globally available, but the active span lives in a contextvars slot managed by the opentelemetry.context module. That slot does not automatically follow your code into every concurrency primitive.

Step 2: Propagate context across asyncio tasks

On Python 3.11+, asyncio.create_task copies contextvars into the child task. On earlier versions, or when you use loop.call_soon, run_in_executor, or asyncio.to_thread, the OTel context is lost. Capture and re-attach explicitly.

from opentelemetry import context as otel_context
import asyncio

async def traced_task(coro):
    ctx = otel_context.get_current()
    def wrapper():
        token = otel_context.attach(ctx)
        try:
            return asyncio.run(coro)
        finally:
            otel_context.detach(token)
    return await asyncio.to_thread(wrapper)

For pure asyncio, use the built-in context wrapper:

async def child_span():
    with tracer.start_as_current_span("child"):
        await asyncio.sleep(0.1)

async def parent():
    with tracer.start_as_current_span("parent"):
        ctx = otel_context.get_current()
        # with_current_context attaches ctx inside the coroutine when it runs
        task = asyncio.create_task(otel_context.with_current_context(child_span())())
        await task

If you omit with_current_context on Python <3.11, the child span becomes a root span with a new trace ID. That is the most common bug in OpenTelemetry context propagation async pipelines.

Step 3: Inject trace headers into async LLM calls

When you POST to an OpenAI-compatible endpoint, the HTTP client must emit traceparent and tracestate headers. Use propagation.inject on a mutable headers dict.

from opentelemetry.propagate import inject
import httpx

async def call_llm(prompt: str):
    headers = {}
    inject(headers)  # adds traceparent/tracestate
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(
            "https://api.openai.com/v1/chat/completions",
            headers=headers,
            json={"model": "gpt-4o", "messages": [{"role": "user", "content": prompt}]},
        )
        resp.raise_for_status()
        return resp.json()

If you route through a gateway such as n4n.ai, the same injection works against its single OpenAI-compatible endpoint; the gateway honors client routing directives and forwards provider cache-control hints, but your client retains the parent span. Wrap the call in a span to capture latency and token counts:

async def traced_llm_call(prompt):
    with tracer.start_as_current_span("llm.call") as span:
        span.set_attribute("llm.prompt", prompt[:200])
        result = await call_llm(prompt)
        usage = result.get("usage", {})
        span.set_attribute("llm.total_tokens", usage.get("total_tokens", 0))
        return result

Without the inject step, the LLM provider’s internal spans (if it emits any) cannot nest under your trace, and you lose the ability to correlate provider-side latency with your orchestration logic.

Step 4: Extract context in downstream workers

If your LLM fan-out pushes jobs to a queue (Redis, SQS, Celery), HTTP header injection is useless. Serialize the OTel context into the job payload and extract it on the consumer.

from opentelemetry.propagate import inject, extract
import json, redis

r = redis.Redis()

def enqueue_job(job_data: dict):
    carrier = {}
    inject(carrier)
    job_data["otel_carrier"] = carrier
    r.rpush("llm_jobs", json.dumps(job_data))

def process_job(raw: str):
    job = json.loads(raw)
    ctx = extract(job.get("otel_carrier", {}))
    token = otel_context.attach(ctx)
    try:
        with tracer.start_as_current_span("worker.process"):
            # call LLM or run post-processing
            ...
    finally:
        otel_context.detach(token)

This restores the parent trace ID so worker spans nest under the original request. Forgetting to extract produces a flat trace with disconnected worker nodes—a classic symptom of broken OpenTelemetry context propagation async handoffs.

Step 5: Bind context to streaming callbacks

LLM streaming and tool-call callbacks fire in separate coroutine chains. Capture the context at registration, not at call time.

def make_callback(original_ctx):
    async def callback(chunk: dict):
        token = otel_context.attach(original_ctx)
        try:
            with tracer.start_as_current_span("llm.stream_chunk"):
                # e.g., accumulate or forward chunk
                pass
        finally:
            otel_context.detach(token)
    return callback

async def stream_llm(prompt):
    ctx = otel_context.get_current()
    cb = make_callback(ctx)
    # hypothetical async generator from an LLM SDK
    async for chunk in fake_stream(prompt):
        await cb(chunk)

If you register the callback without binding original_ctx, the streaming spans appear under a fresh root and your trace breaks exactly where latency matters most: during token generation.

Step 6: Verify end-to-end trace continuity

Point the exporter at a collector (OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317) and run a request that triggers at least two async LLM calls plus one queued worker. Inspect the trace in Jaeger or Tempo.

Success criteria:

  • The root span (POST /chat) has child spans llm.call for each async task, with correct parent_id.
  • Worker spans show the same trace_id as the root and a non-null parent_id.
  • No orphan spans with parent_id empty that correspond to known LLM paths.

A lightweight unit test using the in-memory exporter:

from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

def test_propagation():
    exporter = InMemorySpanExporter()
    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    t = trace.get_tracer("test")

    with t.start_as_current_span("root") as root:
        ctx = otel_context.get_current()
        with otel_context.with_current_context(t.start_as_current_span("child"))() as child:
            pass
    spans = exporter.get_finished_spans()
    assert spans[1].parent == root.get_span_context()

Gotchas that will bite you

  • asyncio.to_thread does not copy OTel context. Attach manually as shown in Step 2.
  • FastAPI BackgroundTasks run after the response is sent; the request span is closed unless you pass the captured context into the task.
  • If your root span is sampled out (e.g., TraceIdRatioBased at low rate), all children vanish. Use ParentBased(AlwaysOn) in development.
  • Context attach returns a token; failing to detach leaks contextvars and can cause spans to cross wires under load.

OpenTelemetry context propagation async patterns add a few lines per boundary, but they are the difference between a trace that explains a slow multi-model fan-out and a dashboard of unexplained latency. Implement the inject/extract pair at every I/O edge and your LLM observability will survive any concurrency trick you throw at it.

Tagsopentelemetryasynccontext-propagationtracing

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All opentelemetry tracing for llm apps posts →