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:

ConceptPurposeTypical Header
Trace ContextCarries the trace ID, span ID, and sampling flags.traceparent (W3C)
BaggageArbitrary 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 – version
  • 4bf92f… – 16‑byte trace ID
  • 00f067… – 8‑byte span ID
  • 01 – 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.

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:

  • FlaskInstrumentor automatically extracts traceparent from incoming HTTP requests.
  • propagators.inject writes the current context into a mutable carrier (headers dict) 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 GlobalOpenTelemetry singleton holds the configured propagators (by default TraceContextPropagator + BaggagePropagator).
  • TextMapSetter bridges OpenTelemetry’s generic injection API to Kafka’s ProducerRecord header model.
  • On the consumer side, the KafkaConsumer instrumented 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 ensures traceparent is 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 (filelog exporter) 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:

  1. Inject before producer.send.
  2. 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 traceparent from HTTP headers.
  • AWS: X‑Ray can be configured to respect W3C headers via the AWS_XRAY_CONTEXT_MISSING=LOG_ERROR env var and the aws-xray-sdk wrapper.

Ensuring a single format prevents “trace split” where one provider sees a separate trace tree.

Common Pitfalls and Failure Modes

  • Missing traceparent on 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 with context.WithValue or the SDK’s propagation utilities.
  • 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 BatchSpanProcessor and configure a reasonable max_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).

Further Reading