> ## Documentation Index
> Fetch the complete documentation index at: https://arize-ax.mintlify.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Context Propagation

> How OpenTelemetry carries trace context across async boundaries, threads, and microservices — when propagation is automatic, when it's manual, and which propagators to use.

OpenTelemetry tracks the current span via a **context** — an immutable object that holds the active span's ID, trace ID, and a few other pieces of metadata. When you call `tracer.start_as_current_span(...)`, OTel reads the current context, sets the new span as a child, and updates the context.

Within a single thread of execution, this is automatic. Across threads, async boundaries, or service boundaries, the context doesn't follow on its own — you have to propagate it.

# The Context Object

Every span carries an immutable Context object containing:

| Field           | Description                                                           |
| :-------------- | :-------------------------------------------------------------------- |
| **Span ID**     | The current span's ID.                                                |
| **Trace ID**    | The trace this span belongs to.                                       |
| **Trace flags** | Binary encoding of trace-level info (e.g., the sampled flag).         |
| **Trace state** | A list of key-value pairs with vendor-specific trace info.            |
| **Baggage**     | Arbitrary contextual key-value data that travels alongside the trace. |

**Baggage** is the channel for non-tracing data that should follow the request — feature flags, tenant IDs, request type. Baggage doesn't become span attributes automatically; if you want it on spans, you have to read it and set it explicitly.

# When Propagation Is Needed (and When It Isn't)

| Propagation is **NOT** needed             | Propagation **IS** needed                          |
| :---------------------------------------- | :------------------------------------------------- |
| Between function calls in the same module | Crossing process or service boundaries             |
| Between modules in the same process       | HTTP requests                                      |
| Inside the same request lifecycle         | gRPC calls                                         |
| Async code in the same process            | Async jobs / background workers (separate process) |
|                                           | Any network boundary                               |

Most application code falls into the left column — context propagation just works. The right column is where you need the propagators below.

# Automatic Propagation

In Python, OTel uses [`contextvars`](https://docs.python.org/3/library/contextvars.html) under the hood, which propagates context cleanly across:

* Synchronous function calls.
* `asyncio` tasks within the same event loop.
* `await` boundaries.

You don't have to do anything special — `tracer.start_as_current_span(...)` works as you'd expect.

# Manual Context Propagation

When automatic propagation breaks down — across threads, across services, into background workers — these are the tools OTel gives you:

| Tool                                            | Use                                                                                                  |
| :---------------------------------------------- | :--------------------------------------------------------------------------------------------------- |
| `context.get_current()`                         | Read the current context from outside the current execution path (custom threads, tasks, callbacks). |
| `context.attach(ctx)` / `context.detach(token)` | Activate a previously captured context in a different thread or task.                                |
| `set_baggage(key, value)`                       | Set a baggage value in the current context, to read later in the same execution path.                |
| `DefaultTextMapPropagator()`                    | Inject/extract context across service boundaries (HTTP/gRPC).                                        |

`DefaultTextMapPropagator()` is a convenience symbol in the Python `opentelemetry-api` package that constructs a composite propagator combining:

| Propagator                        | Carries                                                          |
| :-------------------------------- | :--------------------------------------------------------------- |
| `TraceContextTextMapPropagator()` | Trace context (W3C `traceparent` and `tracestate` headers) only. |
| `W3CBaggagePropagator()`          | Baggage only.                                                    |

The OTel specification doesn't define a propagator named `DefaultTextMapPropagator` — it just requires that SDKs default to a composite of the W3C Trace Context and Baggage propagators. JS/Go/Java equivalents construct the same composite under slightly different names; check the [Propagators spec](https://opentelemetry.io/docs/specs/otel/context/api-propagators/) for your language.

For the Python API reference, see [OpenTelemetry Context API](https://opentelemetry-python.readthedocs.io/en/latest/api/context.html) and [Propagators API](https://opentelemetry-python.readthedocs.io/en/latest/api/propagators.html).

# Async Functions (Same Service)

When you launch async work from sync code, the context doesn't always follow. Capture it explicitly:

```python theme={null}
import asyncio
from opentelemetry import trace
from opentelemetry.context import attach, detach, get_current

tracer = trace.get_tracer(__name__)

async def async_func(ctx):
    token = attach(ctx)
    try:
        current_span = trace.get_current_span()
        current_span.set_attribute("input.value", "User Input")
        await asyncio.sleep(1)  # Simulate async work
    finally:
        detach(token)

def sync_func():
    with tracer.start_as_current_span("sync_span") as span:
        context = get_current()
        asyncio.run(async_func(context))
```

The pattern:

1. Capture the current context **before** launching async work: `context = get_current()`.
2. Pass the context into the async function.
3. Inside the async function, `attach(ctx)` and store the returned token.
4. In a `finally`, `detach(token)` to restore the previous context.

For more patterns including `ThreadPoolExecutor`, see [Advanced Patterns: Manual Context Propagation](/ax/instrument/advanced-patterns#manual-context-propagation).

# Across Microservices

Crossing a service boundary is where propagators earn their keep. Service A injects the current context into outbound request headers; Service B extracts it on the inbound side and uses it as the parent for its own spans.

**Service A** (caller):

```python theme={null}
import requests
from opentelemetry import trace
from opentelemetry.propagators.textmap import DefaultTextMapPropagator

tracer = trace.get_tracer(__name__)

def service_a():
    with tracer.start_as_current_span("llm_service_a") as span:
        headers = {}  # Prepare headers to be injected with context
        DefaultTextMapPropagator().inject(carrier=headers)  # Inject current context
        response = requests.get("http://service-b:5000/endpoint", headers=headers)
        return response
```

**Service B** (callee):

```python theme={null}
from opentelemetry import trace
from opentelemetry.propagators.textmap import DefaultTextMapPropagator

tracer = trace.get_tracer(__name__)

def service_b(request):
    context = DefaultTextMapPropagator().extract(carrier=dict(request.headers))
    # Create a new span as child of the extracted context
    with tracer.start_as_current_span("llm_service_b", context=context) as span:
        ...
```

After this, Service B's span appears as a child of Service A's span — even though they ran in different processes — and both appear under the same trace ID in the Arize AX UI.

```mermaid theme={null}
sequenceDiagram
    participant A as Service A
    participant B as Service B
    Note over A: start_as_current_span("llm_service_a")<br/>trace_id: T1, span_id: S1
    Note over A: DefaultTextMapPropagator().inject(headers)
    A->>+B: HTTP GET /endpoint<br/>traceparent: 00-T1-S1-01
    Note over B: DefaultTextMapPropagator().extract(headers)
    Note over B: start_as_current_span("llm_service_b", context=ctx)<br/>trace_id: T1, span_id: S2, parent_id: S1
    B-->>-A: response
    Note over A,B: Both spans share trace_id T1.
```

# Common Propagation Failure Modes

A few patterns that produce orphaned or broken traces:

* **Forgetting to inject context on the caller side** — the callee starts a new trace instead of joining the existing one. Symptom: caller and callee appear as separate traces in the Arize AX UI.
* **Using different propagators on each side** — if Service A injects W3C headers and Service B only extracts a different format, the context is silently lost. Stick with `DefaultTextMapPropagator()` everywhere unless you have a specific reason not to.
* **Stripping headers in the network layer** — proxies, API gateways, and service meshes sometimes strip headers they don't recognize. The W3C `traceparent` header is usually safe but verify with your infrastructure team.
* **Async work that escapes the request lifecycle** — fire-and-forget tasks (background workers, queues) need explicit context capture before submission.

***

## Next step

Context propagation is about making sure spans are connected. Sampling is about which spans get recorded at all:

<Card title="Next: Sampling" icon="arrow-right" href="/ax/concepts/otel-openinference/sampling" />
