Propagating Trace Context Through Tile Pipelines

A tile request that hands off to an asynchronous generation worker through a queue loses its parent trace unless the W3C traceparent is carried explicitly across the boundary, because message brokers do not forward HTTP headers and the SDK’s automatic context ends at the process edge. This operation injects and extracts traceparent across both an HTTP hop and a Kafka queue hop, and carries crs and bbox as OpenTelemetry baggage, so a tile request stays traceable end to end even when its work runs minutes later on a different node. It is the propagation procedure the instrumentation in Distributed Tracing for Spatial Request Flows assumes, within Spatial Pipeline Orchestration & Observability, and it keeps the async tile path consistent with the idempotent DAGs of Orchestrating Spatial Pipelines in Python.

Prerequisites

Requirement Value / Assumption Notes
OTel SDK opentelemetry-sdk >= 1.24 Python; propagate + baggage APIs
Propagators tracecontext + baggage composite W3C headers
Broker Kafka >= 3.5 with message headers Carries traceparent per record
HTTP client httpx or requests with header injection Downstream HTTP hop
CRS baggage crs, bbox propagated as baggage Drives downstream tile decisions
Collector OTLP endpoint over mTLS Zero-trust export
Env var OTEL_PROPAGATORS=tracecontext,baggage Enables composite propagation
Access role pipeline-instrumentation (RBAC) Deploy instrumented workers

Figure — Trace context is injected into the HTTP request, re-injected into the Kafka record headers at the async boundary, then extracted by the worker so the span tree stays connected.

Trace context propagation across HTTP and a queue boundary A client request reaches an API service, which extracts the inbound traceparent, then injects it into a Kafka record's headers along with crs and bbox baggage. The tile worker consumes the record, extracts the context, and continues the same trace, so spans across the async boundary remain linked. Client GET /tiles API service extract + inject Kafka topic headers carry ctx Tile worker extract + continue traceparent traceparent + baggage crs · bbox extract

Step-by-Step Implementation

Each step carries the context one hop further and verifies the link before the next.

1. Configure the composite propagator

Trace context and baggage travel in separate headers, so the process must run both propagators. Set them globally at startup so every inject/extract uses the same format.

python
# propagation_setup.py — enable W3C tracecontext + baggage globally.
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

set_global_textmap(CompositePropagator([
    TraceContextTextMapPropagator(),   # traceparent header
    W3CBaggagePropagator(),            # baggage header (crs, bbox)
]))

Verify: confirm both propagator fields are advertised.

bash
python -c "from opentelemetry.propagate import get_global_textmap; print(get_global_textmap().fields)"
# expect: {'traceparent', 'tracestate', 'baggage'}

2. Extract the inbound context and set spatial baggage

At the API service, extract the parent context from the incoming HTTP headers so the API span is a child of the caller, then attach crs and bbox as baggage that will ride downstream.

python
# api_ingest.py — continue the inbound trace and set spatial baggage.
from opentelemetry import trace, baggage, context
from opentelemetry.propagate import extract

def handle_request(headers: dict, crs: str, bbox: str):
    ctx = extract(headers)                        # parent from inbound traceparent
    ctx = baggage.set_baggage("crs", crs, context=ctx)
    ctx = baggage.set_baggage("bbox", bbox, context=ctx)
    tracer = trace.get_tracer("api-service")
    with tracer.start_as_current_span("tiles.enqueue", context=ctx):
        return ctx                                # carried into the producer

Verify: log the active context’s trace id to confirm it matches the caller’s.

bash
curl -s -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  http://localhost:8000/tiles?bbox=... | jq '.trace_id'   # must echo 4bf92f35...

3. Inject context into the Kafka record headers

The broker will not forward the HTTP header, so inject traceparent and baggage into the record’s message headers as bytes. This is the boundary where propagation is most often lost.

python
# producer.py — carry trace context across the async boundary.
from opentelemetry.propagate import inject

def enqueue_tile_job(producer, topic: str, payload: bytes, ctx):
    carrier: dict[str, str] = {}
    inject(carrier, context=ctx)                  # writes traceparent + baggage
    headers = [(k, v.encode()) for k, v in carrier.items()]
    producer.produce(topic, value=payload, headers=headers)  # ctx rides the record
    producer.flush()

Verify: read the record headers back off the topic and confirm traceparent is present.

bash
kcat -b broker:9092 -t tile-jobs -C -c1 -f '%h\n'   # prints headers; expect traceparent=...

4. Extract context in the worker and continue the trace

The consumer rebuilds the carrier from the record headers, extracts the context, and opens the tile-generation span as a child — reconnecting the tree across the queue.

python
# worker.py — resume the trace in the async tile generator.
from opentelemetry import trace, baggage
from opentelemetry.propagate import extract

def process_record(record):
    carrier = {k: v.decode() for k, v in record.headers()}
    ctx = extract(carrier)                         # parent from the record headers
    crs = baggage.get_baggage("crs", context=ctx)  # spatial context survives the hop
    bbox = baggage.get_baggage("bbox", context=ctx)
    tracer = trace.get_tracer("tile-worker")
    with tracer.start_as_current_span("tile.generate", context=ctx) as span:
        span.set_attribute("crs", crs)
        span.set_attribute("bbox", bbox)
        return render(record.value(), crs, bbox)

Verify: query the backend for the trace id and confirm the worker span is a descendant, not a new root.

bash
curl -s "http://otel-query.mesh.internal/api/traces/4bf92f35...b34da6" \
  | jq '[.spans[].operationName]'   # includes tile.generate under the same trace

Configuration Reference

Field Scope Required value Effect
OTEL_PROPAGATORS env tracecontext,baggage Enables both header formats
traceparent HTTP + Kafka header W3C format Links child span to parent
baggage HTTP + Kafka header crs=…,bbox=… Carries spatial context downstream
record headers Kafka producer bytes-encoded carrier Transports context across the broker
extract(carrier) consumer inbound context Reparents the worker span
start_as_current_span(context=ctx) worker explicit ctx Prevents a new root span
baggage value size convention keep small Baggage rides every downstream hop

Common Failure Modes & Fixes

Worker spans appear as separate root traces. Root cause: start_as_current_span was called without the extracted context=ctx, so the SDK opens a fresh root. Fix: pass the extracted context explicitly into the span start in the consumer.

traceparent missing from Kafka records. Root cause: inject wrote to a carrier that was never attached to the record, or headers were dropped by a serializer. Fix: encode the carrier into the record headers as bytes and confirm with kcat -f '%h'.

Baggage is empty in the worker. Root cause: only the tracecontext propagator is configured, so the baggage header is neither written nor read. Fix: register the composite propagator with both TraceContextTextMapPropagator and W3CBaggagePropagator.

Trace links but crs/bbox are wrong downstream. Root cause: baggage was set on a context that was not the one injected, so a stale value rode the record. Fix: thread the same ctx through set-baggage, span start, and inject without reassigning it in between.

Context leaks across unrelated jobs in the worker. Root cause: the extracted context was attached globally and never detached, so the next record inherits the previous trace. Fix: scope the context to the with block per record and never call a bare context.attach without a matching detach.

FAQ

Why doesn’t the SDK propagate context across Kafka automatically?

Automatic instrumentation covers in-process calls and HTTP clients that share the header namespace, but a message broker is an opaque transport that carries bytes, not HTTP headers. The producer must explicitly serialize the context into the record’s message headers and the consumer must explicitly extract it, because nothing in the broker understands traceparent. This manual inject/extract at the queue boundary is the defining step of the pattern.

What belongs in baggage versus a span attribute?

Baggage is for values a downstream hop needs to make a decision or to tag its own spans — here crs and bbox, which the worker records on its generation span. A span attribute is for values you only need on the span where they are set. Keep baggage small, because it is copied onto every subsequent hop; putting a large payload in baggage inflates every record and request that follows.

How do I confirm the async hop actually preserved the trace?

Look up the original trace_id in the backend and assert that the worker’s tile.generate span appears under it as a descendant rather than as a new root. The verification in step 4 does exactly this. A trace that fractures into two roots at the queue is the signature of a lost traceparent, and it should fail a game-day drill before it fails in production.

Does propagation interfere with idempotency of the tile job?

No — trace context is diagnostic metadata and carries no processing semantics, so a retried record with the same traceparent is still deduplicated by the job’s own idempotency key. The two concerns are orthogonal: the idempotency key prevents double work, while the trace context links whichever attempt actually ran, which is why both ride the same record without conflict.