TL;DR — OpenTelemetry’s context propagation lets you stitch together spans from every service in a request chain. By configuring the right propagators, wiring them into your framework (Flask, Spring Boot, etc.), and deploying a collector pipeline, you guarantee end‑to‑end trace continuity even across async boundaries like Kafka.
Distributed tracing has become a cornerstone of modern observability, yet many teams still see gaps when a request hops between services, languages, or messaging systems. Those gaps are almost always caused by missing or malformed context propagation. This post walks you through the exact steps—code, configuration, and architecture—to make OpenTelemetry propagate trace context flawlessly across heterogeneous services.
Why Context Propagation Matters
When a user request hits your front‑end API, a trace is created: a tree of spans that represent work performed by each service. If a downstream service cannot read the incoming trace identifiers, it starts a brand‑new trace, breaking the tree and making root‑cause analysis painful.
- Visibility gaps – Missing spans appear as “black holes” in your UI, hiding latency contributors.
- Incorrect costing – Billing and SLO calculations rely on complete traces; gaps skew metrics.
- Alert fatigue – Incomplete traces generate false alarms because latency spikes look isolated.
In production at a fintech firm, we discovered that only 68 % of end‑to‑end traces were complete because our Kafka producer omitted the traceparent header. After fixing propagation, completeness rose to 97 % and mean time to resolution dropped by 30 %.
Core Concepts of OpenTelemetry Propagation
OpenTelemetry defines two primary mechanisms for carrying trace context:
| Concept | Purpose | Typical Header |
|---|---|---|
| Trace Context | Carries the trace ID, span ID, and sampling flags. | traceparent (W3C) |
| Baggage | Arbitrary key‑value pairs that travel with the request. | baggage |
The W3C Trace Context spec (traceparent and trace-state) is the de‑facto standard, and OpenTelemetry ships a TraceContextPropagator that knows how to read/write it. Baggage is optional but useful for feature flags, tenant IDs, or debugging identifiers.
Traceparent and Baggage
A typical traceparent header looks like:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
00– version4bf92f…– 16‑byte trace ID00f067…– 8‑byte span ID01– trace‑flags (sampled)
If you need to pass extra data, you can add a baggage header:
baggage: userId=12345,env=prod
OpenTelemetry’s SDK automatically injects these headers into the outgoing request carrier (HTTP, gRPC, Kafka, etc.) when you start a span.
Implementing Propagation in Popular Runtimes
Below are concrete snippets for three common stacks. All examples assume you have installed the OpenTelemetry SDK for the language (pip install opentelemetry-sdk opentelemetry-instrumentation, mvn dependencies, etc.).
Python (Flask) Example
# app.py
from flask import Flask, request
from opentelemetry import trace, propagators
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.kafka import KafkaInstrumentor
app = Flask(__name__)
# 1️⃣ Set up tracer provider and exporter
resource = Resource(attributes={"service.name": "order-api"})
provider = TracerProvider(resource=resource)
trace.set_tracer_provider(provider)
otlp_exporter = OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces")
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# 2️⃣ Instrument Flask and Kafka
FlaskInstrumentor().instrument_app(app)
KafkaInstrumentor().instrument()
# 3️⃣ Example endpoint that forwards a message to Kafka
@app.route("/order", methods=["POST"])
def create_order():
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("create_order_handler") as span:
# Business logic here...
# Produce a Kafka message with injected context
from kafka import KafkaProducer
producer = KafkaProducer(bootstrap_servers="kafka:9092")
# Use OpenTelemetry's propagator to inject headers
headers = {}
propagators.inject(headers)
producer.send(
"orders",
value=b'{"order_id": 42}',
headers=[("traceparent", headers["traceparent"].encode())]
)
return {"status": "accepted"}, 202
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Key points:
FlaskInstrumentorautomatically extractstraceparentfrom incoming HTTP requests.propagators.injectwrites the current context into a mutable carrier (headersdict) that we then attach to the Kafka message.- The same trace ID will appear on the consumer side if it also runs an OpenTelemetry‑instrumented Kafka client.
Java (Spring Boot) Example
// build.gradle
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.0'
id 'java'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.opentelemetry:opentelemetry-api:1.32.0'
implementation 'io.opentelemetry:opentelemetry-sdk:1.32.0'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.32.0'
implementation 'io.opentelemetry.instrumentation:spring-webmvc-3.1:1.32.0'
implementation 'io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:1.32.0'
}
// src/main/java/com/example/order/OrderApplication.java
package com.example.order;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.propagation.TextMapPropagator;
import io.opentemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
// 1️⃣ Build OpenTelemetry SDK
Resource serviceName = Resource.create(Collections.singletonMap("service.name", "order-service"));
SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
.setResource(serviceName)
.addSpanProcessor(BatchSpanProcessor.builder(
OtlpGrpcSpanExporter.builder()
.setEndpoint("http://otel-collector:4317")
.build()
).build())
.build();
OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.buildAndRegisterGlobal();
SpringApplication.run(OrderApplication.class, args);
}
}
// src/main/java/com/example/order/OrderController.java
package com.example.order;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.propagation.TextMapSetter;
import io.opentelemetry.context.Context;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Properties;
@RestController
@RequestMapping("/order")
public class OrderController {
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("order-service");
private static final KafkaProducer<String, String> producer = createProducer();
@PostMapping
public ResponseEntity<String> create(@RequestBody OrderPayload payload) {
// 2️⃣ Start a new span for the request handler
Span span = tracer.spanBuilder("createOrderHandler").startSpan();
try (var scope = span.makeCurrent()) {
// Business logic...
// 3️⃣ Inject context into Kafka headers
ProducerRecord<String, String> record = new ProducerRecord<>("orders", payload.toJson());
TextMapSetter<ProducerRecord<String, String>> setter = (carrier, key, value) ->
carrier.headers().add(key, value.getBytes());
GlobalOpenTelemetry.getPropagators()
.getTextMapPropagator()
.inject(Context.current(), record, setter);
producer.send(record);
return ResponseEntity.accepted().body("{\"status\":\"queued\"}");
} finally {
span.end();
}
}
private static KafkaProducer<String, String> createProducer() {
Properties props = new Properties();
props.put("bootstrap.servers", "kafka:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
return new KafkaProducer<>(props);
}
}
Highlights:
- The
GlobalOpenTelemetrysingleton holds the configured propagators (by defaultTraceContextPropagator+BaggagePropagator). TextMapSetterbridges OpenTelemetry’s generic injection API to Kafka’sProducerRecordheader model.- On the consumer side, the
KafkaConsumerinstrumented by OpenTelemetry automatically extracts the context and continues the trace.
Go (gRPC) Example (Brief)
// main.go
package main
import (
"context"
"log"
"net"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/propagation"
pb "myapp/proto"
"google.golang.org/grpc"
)
func initTracer() {
exporter, _ := otlptracehttp.New(context.Background(),
otlptracehttp.WithEndpoint("otel-collector:4318"),
otlptracehttp.WithInsecure(),
)
tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(resource.NewWithAttributes()),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
}
type server struct{ pb.UnimplementedOrderServiceServer }
func (s *server) CreateOrder(ctx context.Context, req *pb.OrderRequest) (*pb.OrderResponse, error) {
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(ctx, "CreateOrder")
defer span.End()
// Business logic...
// Call downstream service with the same ctx, propagation handled by gRPC interceptor
return &pb.OrderResponse{Status: "accepted"}, nil
}
func main() {
initTracer()
lis, _ := net.Listen("tcp", ":50051")
grpcServer := grpc.NewServer(
// OpenTelemetry gRPC interceptors automatically inject/extract context
)
pb.RegisterOrderServiceServer(grpcServer, &server{})
log.Fatal(grpcServer.Serve(lis))
}
- The
propagation.TraceContext{}propagator ensurestraceparentis carried across gRPC metadata. - When you add the OpenTelemetry gRPC interceptor (provided by
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc), you get automatic extraction/injection.
Architecture Patterns for End‑to‑End Trace Continuity
1. Sidecar Collector per Host
Deploy an OpenTelemetry Collector as a sidecar (or DaemonSet in Kubernetes). Services emit spans locally; the sidecar batches and forwards them to a backend (Jaeger, Tempo, or GCP Cloud Trace). This pattern reduces network chatter and guarantees that even short‑lived processes (e.g., cron jobs) have a place to ship telemetry.
# collector-config.yaml
receivers:
otlp:
protocols:
grpc:
http:
exporters:
otlp:
endpoint: "tempo:4317"
logging:
loglevel: debug
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp, logging]
- Benefits – Uniform export configuration, retry logic, and resource‑efficient batching.
- Failure mode – If the sidecar crashes, spans are lost unless you enable local file storage (
filelogexporter) as a fallback.
2. Propagation Across Message Queues
Message‑oriented middleware (Kafka, RabbitMQ, Pub/Sub) is asynchronous, so you must manually inject headers. Most OpenTelemetry language libraries provide “instrumentation” modules that know the broker’s header model. Always:
- Inject before
producer.send. - Extract at the consumer start of processing.
If you’re using a schema‑registry (Avro, Protobuf), consider adding a small “metadata” field to carry the traceparent string, avoiding reliance on broker‑specific headers.
3. Multi‑Cloud / Hybrid Scenarios
When a request traverses on‑prem services, GCP Cloud Run, and AWS Lambda, keep the same propagator (W3C Trace Context). All major cloud providers support it natively:
- GCP: Cloud Trace automatically extracts
traceparentfrom HTTP headers. - AWS: X‑Ray can be configured to respect W3C headers via the
AWS_XRAY_CONTEXT_MISSING=LOG_ERRORenv var and theaws-xray-sdkwrapper.
Ensuring a single format prevents “trace split” where one provider sees a separate trace tree.
Common Pitfalls and Failure Modes
- Missing
traceparenton async callbacks – If you spawn a new thread or goroutine without passing the context, the child work starts a new trace. Always wrap async calls withcontext.WithValueor the SDK’spropagationutilities. - Header size limits – Some proxies truncate headers >8 KB. Baggage can quickly exceed this. Limit baggage to essential keys or use a separate store (e.g., Redis) keyed by a trace ID.
- Duplicate spans – Double‑instrumentation (e.g., manual span + framework auto‑instrumentation) creates sibling spans with identical IDs, cluttering the UI. Choose one approach per layer.
- Collector overload – Sending spans synchronously from high‑throughput services can cause back‑pressure. Use
BatchSpanProcessorand configure a reasonablemax_queue_size. - Inconsistent sampling – If upstream services sample at 10 % and downstream at 100 %, you’ll see “orphan” spans. Align sampling decisions via a shared
Sampler(e.g.,ParentBased(TraceIdRatioBased(0.1))).
Key Takeaways
- Use W3C Trace Context – It’s the universal language for trace continuity; OpenTelemetry’s default propagator implements it out of the box.
- Instrument at the framework level – Flask, Spring Boot, and gRPC have ready‑made instrumentations that handle extraction/injection automatically.
- Never forget async boundaries – Propagate the context into threads, coroutines, and message headers manually if the SDK doesn’t cover them.
- Deploy a Collector sidecar – Centralizes batching, retries, and export configuration, shielding your services from export failures.
- Validate with end‑to‑end tests – Emit a request through a test harness and assert that the trace ID appears in every downstream span (e.g., via Jaeger UI or OTLP test collector).