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.

What belongs in baggage, what belongs on a span, and what belongs in neitherFour kinds of context compared on three properties. CRS, bounding box and layer travel as baggage because every downstream hop needs them to make its own decisions, and they are small. Span duration and row counts are span attributes: recorded once where they are measured, never propagated. Credentials must be in neither, since baggage crosses trust boundaries in cleartext headers. A full product manifest belongs in neither either, because baggage costs bytes on every hop in the estate.BaggageSpan attributeNeithercrs · bbox · layeryesmirroreddomain idyesmirroredrows returned, durationyescredentials, tokensneverfull product manifesttoo heavy

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.

Where the trace context is lost, and where it has to be re-injected by handA tile request arrives at the ingress with a traceparent header, which the API instruments automatically. When the API publishes a job onto Kafka the SDK does not carry context across the broker, so the producer must inject traceparent and baggage into the record headers explicitly. The worker then extracts them from the record and continues the same trace, rather than starting a new root. Without the explicit inject and extract, the worker span appears as an unrelated root trace and the async half of the request becomes invisible.IngressTile APIKafkaRender workertraceparent (auto)inject into record headersmanual — the SDK stops hererecord deliveredextract → continue same trace

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.

What happens to the trace when a tile job is retried from the dead-letter queue?

A dead-letter replay is a new trace, and it should be. The original trace already records what happened up to the failure and is the evidence for why the record was parked; grafting a replay onto it days later produces a trace whose duration is meaningless. Instead, start a fresh trace for the replay and add a span link back to the original trace and span identifiers, which most backends render as a navigable relationship. The replay is then independently timeable while remaining one click from the failure that caused it. Keep the spatial baggage — crs, bbox, layer — identical across both, since that is what lets a query find every attempt at the same partition regardless of which trace it belonged to.

Does sampling have to be decided before baggage is set?

Yes, and the ordering matters more than it looks. The sampling flag travels in traceparent, and baggage travels in a separate baggage header, so a service that sets baggage on a request whose trace was never sampled pays the byte cost on every hop for context nobody will read. Decide sampling at the ingress gateway, propagate the decision unchanged, and set baggage only on the path — which is also why a downstream service must never re-decide sampling. A hop that samples independently produces a trace with a gap in the middle, and a gap reads as “this service was not called”, which is the most misleading thing a trace can say.