TL;DR — OpenTelemetry’s context propagation lets you stitch together spans across service boundaries. By using the W3C TraceContext format and wiring the SDK into your language runtimes (Go, Java, Python), you can achieve end‑to‑end visibility in a production microservices mesh with minimal latency overhead.

Distributed systems only make sense when you can see what is happening across their many moving parts. A request that traverses an API gateway, a payment service, a fraud‑detection engine, and a data warehouse can generate dozens of spans, yet without proper context propagation those spans appear as isolated islands. In this post we’ll unpack the mechanics of OpenTelemetry context propagation, walk through concrete implementations in three popular runtimes, and tie everything together with architecture‑level patterns that survive real‑world traffic spikes, retries, and partial failures.

Why Context Propagation Matters

When a request enters a system, a trace ID is generated (or extracted from an inbound header). Every subsequent service that participates in the request must:

  1. Extract the existing trace context from inbound headers.
  2. Create a child span that inherits the trace ID and sets its own span ID.
  3. Inject the updated context into outbound calls.

If any step is missed, the tracing graph breaks, leading to:

  • Gaps in latency analysis – you can’t pinpoint where latency accumulates.
  • Missing error correlation – an exception in service B won’t be linked to the original request in service A.
  • Reduced confidence in SLO monitoring – dashboards show “unknown” spans.

OpenTelemetry standardizes these steps with a vendor‑agnostic API, making it possible to swap exporters (Jaeger, Zipkin, Google Cloud Trace) without touching the propagation logic.

OpenTelemetry Overview

OpenTelemetry consists of three pillars:

PillarResponsibility
APILanguage‑specific interfaces for creating spans, managing context, and recording attributes.
SDKDefault implementation of the API, including processors, exporters, and samplers.
CollectorCentralized agent that receives data from SDKs, buffers, and forwards to back‑ends.

The Context object is a lightweight container that travels with the request. In Go it’s a context.Context; in Java it’s a Context from the OpenTelemetry API; in Python it’s a Context from opentelemetry.context. The key is that the same logical context is passed through function calls, goroutine boundaries, or thread pools.

Propagation Formats

OpenTelemetry supports several propagation formats, but the de‑facto standard for cloud‑native services is the W3C TraceContext specification:

  • traceparent – holds version, trace‑id, parent‑id, and trace‑flags.
  • tracestate – optional vendor‑specific key/value pairs.

A secondary format, Baggage, carries user‑defined key/value pairs that are also propagated. Baggage is useful for things like tenant IDs or feature flags that you want to be visible in every downstream span.

“TraceContext is deliberately minimal; it fits into a single HTTP header and is supported by all major vendors” – as described in the W3C Trace Context spec.

Implementing Propagation in Go

Go’s standard library already uses context.Context for request‑scoped values, making it a natural fit for OpenTelemetry.

1. Set Up the SDK

package main

import (
	"context"
	"log"
	"net/http"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

func initTracer() func(context.Context) error {
	// Export spans via OTLP/HTTP to the collector.
	exporter, err := otlptracehttp.New(context.Background())
	if err != nil {
		log.Fatalf("failed to create exporter: %v", err)
	}

	// Create a resource that identifies this service.
	res, _ := resource.New(context.Background(),
		resource.WithAttributes(
			semconv.ServiceNameKey.String("order-service"),
		),
	)

	// Set up a tracer provider with a simple batch span processor.
	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(res),
	)

	otel.SetTracerProvider(tp)

	// Return a shutdown function.
	return tp.Shutdown
}

2. Propagation Middleware

package main

import (
	"net/http"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
	shutdown := initTracer()
	defer func() {
		if err := shutdown(context.Background()); err != nil {
			log.Fatalf("failed to shutdown tracer: %v", err)
		}
	}()

	mux := http.NewServeMux()
	mux.Handle("/create", otelhttp.NewHandler(http.HandlerFunc(createOrder), "CreateOrder"))

	// otelhttp automatically extracts and injects TraceContext.
	log.Println("Listening on :8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

The otelhttp.NewHandler wrapper does three things:

  1. Extract traceparent and tracestate from the incoming request.
  2. Start a new span named “CreateOrder”.
  3. Inject the updated context into any outbound HTTP calls made from within the handler (provided those calls also use otelhttp or the Go SDK’s httptrace integration).

3. Outbound Calls

func callInventory(ctx context.Context, itemID string) error {
	client := http.Client{
		Transport: otelhttp.NewTransport(http.DefaultTransport),
	}
	req, _ := http.NewRequestWithContext(ctx, "GET", "http://inventory-service/v1/items/"+itemID, nil)
	// The transport injects the current context automatically.
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	// Process response…
	return nil
}

By re‑using otelhttp.NewTransport, the same traceparent header is propagated downstream, ensuring the inventory service can link its span as a child of CreateOrder.

Implementing Propagation in Java (Spring Boot)

Spring Boot developers typically rely on Spring Cloud Sleuth for tracing, but OpenTelemetry can replace it without losing integration with Spring’s WebClient and RestTemplate.

1. Maven Dependencies

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-api</artifactId>
        <version>1.36.0</version>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-sdk</artifactId>
        <version>1.36.0</version>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
        <version>1.36.0</version>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry.instrumentation</groupId>
        <artifactId>opentelemetry-spring-boot-starter</artifactId>
        <version>1.36.0</version>
    </dependency>
</dependencies>

2. Configuration Class

package com.example.payment;

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.propagation.ContextPropagators;
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.semconv.resource.attributes.ResourceAttributes;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OtelConfig {

    @Bean
    public OpenTelemetry openTelemetry() {
        // Exporter sending spans to the collector via gRPC.
        OtlpGrpcSpanExporter exporter = OtlpGrpcSpanExporter.builder()
                .setEndpoint("http://otel-collector:4317")
                .build();

        // Build the tracer provider.
        SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
                .addSpanProcessor(BatchSpanProcessor.builder(exporter).build())
                .setResource(Resource.getDefault()
                        .merge(Resource.create(io.opentelemetry.api.common.Attributes.of(
                                ResourceAttributes.SERVICE_NAME, "payment-service"
                        ))))
                .build();

        return OpenTelemetry.sdkBuilder()
                .setTracerProvider(tracerProvider)
                .setPropagators(ContextPropagators.create(
                        io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator.getInstance()))
                .build();
    }

    @Bean
    public Tracer otelTracer(OpenTelemetry openTelemetry) {
        return openTelemetry.getTracer("com.example.payment");
    }
}

The W3CTraceContextPropagator ensures that incoming traceparent headers are understood and that outbound calls receive the same header.

3. Controller with Span Creation

package com.example.payment;

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/pay")
public class PaymentController {

    private final Tracer tracer;
    private final InventoryClient inventoryClient;

    public PaymentController(Tracer tracer, InventoryClient inventoryClient) {
        this.tracer = tracer;
        this.inventoryClient = inventoryClient;
    }

    @PostMapping("/{orderId}")
    public ResponseEntity<String> charge(@PathVariable String orderId) {
        // Start a span that automatically picks up the extracted context.
        Span span = tracer.spanBuilder("ChargeOrder")
                .setAttribute("order.id", orderId)
                .startSpan();

        try (var scope = span.makeCurrent()) {
            // Business logic…
            inventoryClient.reserve(orderId);
            // Simulate payment gateway call.
            // The client is instrumented; context is injected automatically.
            return ResponseEntity.ok("charged");
        } finally {
            span.end();
        }
    }
}

4. Instrumented Outbound Client

package com.example.payment;

import io.opentelemetry.instrumentation.spring.web.client.RestTemplateInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestClientConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        // Adds the interceptor that injects TraceContext into HTTP headers.
        restTemplate.getInterceptors().add(new RestTemplateInterceptor());
        return restTemplate;
    }
}

Now any downstream microservice that also runs the OpenTelemetry Java SDK will see the traceparent header and create child spans automatically.

Implementing Propagation in Python (FastAPI)

Python’s dynamic nature makes context handling a bit more manual, but the OpenTelemetry Python SDK provides helper libraries for popular frameworks.

1. Install Packages

pip install fastapi uvicorn opentelemetry-sdk opentelemetry-exporter-otlp \
    opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-requests

2. Initialize Tracer Provider

# tracer_setup.py
import os
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.semconv.resource import ResourceAttributes

def init_tracer():
    resource = Resource.create({
        ResourceAttributes.SERVICE_NAME: "shipping-service"
    })
    provider = TracerProvider(resource=resource)
    otlp_exporter = OTLPSpanExporter(endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://otel-collector:4318/v1/traces"))
    provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
    trace.set_tracer_provider(provider)

    # Use W3C TraceContext as the default propagator.
    propagators.set_global_textmap(propagators.get_combined_propagator([
        propagators.TraceContextTextMapPropagator(),
        propagators.BaggagePropagator(),
    ]))

3. FastAPI Application with Automatic Instrumentation

# main.py
import uvicorn
from fastapi import FastAPI, Request
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from tracer_setup import init_tracer

init_tracer()

app = FastAPI(title="Shipping Service")
FastAPIInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()

@app.post("/ship/{order_id}")
async def ship_order(order_id: str, request: Request):
    # The incoming request already has a span attached via the instrumentor.
    # We can add attributes directly.
    span = request.state.span
    span.set_attribute("order.id", order_id)

    # Simulate a downstream call to a notification service.
    import requests
    resp = requests.post(
        "http://notification-service/v1/notify",
        json={"order_id": order_id},
        timeout=2,
    )
    resp.raise_for_status()
    return {"status": "shipped"}

Running uvicorn main:app --host 0.0.0.0 --port 8000 starts a FastAPI server where every incoming HTTP request is automatically:

  1. Extracted (traceparent → context).
  2. Span started (POST /ship/{order_id}).
  3. Injected into any requests call (thanks to RequestsInstrumentor).

Architecture: Distributed Tracing in a Microservices Mesh

Having concrete implementations is only half the battle. In production you need a consistent deployment topology that guarantees low latency, high reliability, and observability across version upgrades.

1. Central Collector Pattern

+-----------+      OTLP/gRPC      +-----------------+      OTLP/HTTP      +----------+
| Service A | ──────────────────▶ | OpenTelemetry  | ◀─────────────────▶ | Jaeger   |
| (Go)      |                     | Collector       |                    | UI       |
+-----------+                     +-----------------+                    +----------+
       ▲                                 ▲
       │                                 │
       │  traceparent & baggage headers  │
       ▼                                 ▼
+-----------+                     +-----------------+
| Service B |                     | Service C (Java)|
| (Python)  |                     +-----------------+
+-----------+

The collector acts as a buffer, applies back‑pressure handling, and can perform tail‑sampling (e.g., keep only 1% of successful traces but 100% of error traces). Deploy the collector as a sidecar per pod or as a DaemonSet, depending on latency requirements.

2. Propagation Across Protocols

While HTTP is the most common, many systems use gRPC, Kafka, or even Redis streams. OpenTelemetry provides language‑specific propagators for each:

ProtocolGo ExampleJava ExamplePython Example
gRPCgrpc-go interceptor (otelgrpc.UnaryServerInterceptor)GrpcTelemetry from opentelemetry-java-instrumentationGrpcInstrumentorClient
Kafkasarama middleware (otelKafka.NewConsumerGroupHandler)KafkaTelemetry from opentelemetry-java-instrumentationopentelemetry-instrumentation-kafka-python
Redisgo-redis hook (otelredis.NewHook)lettuce tracing via opentelemetry-java-instrumentationredis-py instrumentation

When you publish a message, embed the current context into the message headers:

msg := &sarama.ProducerMessage{
    Topic: "orders",
    Value: sarama.StringEncoder(orderJSON),
}
otel.GetTextMapPropagator().Inject(ctx, propagation.NewHeadersCarrier(msg.Headers))
producer.SendMessage(msg)

Downstream consumers then extract the same traceparent to continue the trace across asynchronous boundaries.

3. Sampling Strategies

  • Head Sampling (at trace start) – cheap, but may drop valuable error traces.
  • Tail Sampling (at collector) – allows you to keep all error traces while sampling successes. Example config for the OpenTelemetry Collector:
# collector-config.yaml
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, tail_sampling]
      exporters: [jaeger]

processors:
  tail_sampling:
    policies:
      - name: keep-errors
        type: string_attribute
        string_attribute:
          key: "error"
          values: ["true"]
      - name: random-sample
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

4. Handling Edge Cases

Edge CaseMitigation
Missing headers (e.g., external client)Start a new root span and mark it with span.kind = SERVER and sampling.priority = 0 to avoid inflating metrics.
Header size limits (e.g., Cloudflare)Use Baggage compression or limit baggage to essential keys.
Cross‑region latencyDeploy regional collectors and configure exporter endpoints to forward to a central backend asynchronously.
Partial failures (collector down)SDKs automatically fallback to in‑memory buffering; set max_queue_size to avoid OOM.
Version skew (different SDK versions)Stick to the W3C TraceContext spec; all compliant SDKs interoperate regardless of version.

Patterns in Production

1. “Trace‑First” Service Design

  1. Instrument entry points first – API gateways, message consumers, and background workers.
  2. Propagate context early – wrap HTTP clients, DB drivers, and RPC frameworks.
  3. Add business attributes – order ID, tenant ID, user ID. This enriches the trace for downstream analytics.

2. “Observability as a Sidecar”

Package the OpenTelemetry Collector alongside each service in a sidecar container. Benefits:

  • Isolation – collector failure won’t crash the main app.
  • Uniform configuration – same sampling rules across the mesh.
  • Zero‑code injection – you can add new exporters (e.g., Datadog, New Relic) without changing app code.

3. “Error‑Centric Tail Sampling”

Configure the collector to always retain traces that contain an error attribute (otel.status_code = ERROR). This ensures you have enough data for root‑cause analysis while keeping storage costs low.

4. “Unified Correlation IDs”

While OpenTelemetry supplies trace IDs, many teams also need a business correlation ID (e.g., X-Request-ID). Propagate it via Baggage so it appears on every span:

Baggage baggage = Baggage.builder()
    .put("request-id", requestId)
    .build();
Context ctx = baggage.makeCurrent();

Now dashboards can filter by either trace ID (technical) or request ID (business).

Testing and Observability

  1. Unit Tests – Use the in‑memory exporter (InMemorySpanExporter) to assert that spans are created and contain expected attributes.
  2. Integration Tests – Spin up a local collector (Docker) and verify that end‑to‑end propagation works across services.
  3. Chaos Experiments – Introduce latency or drop headers to see how your fallback logic behaves. Tools like Gremlin or chaos-mesh can automate this.

Example Go unit test:

func TestCreateOrder_PropagatesContext(t *testing.T) {
    exporter := sdktrace.NewInMemoryExporter()
    tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
    otel.SetTracerProvider(tp)

    // Simulate an HTTP request with a traceparent header.
    req := httptest.NewRequest("GET", "/create", nil)
    req.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")

    rr := httptest.NewRecorder()
    handler := otelhttp.NewHandler(http.HandlerFunc(createOrder), "CreateOrder")
    handler.ServeHTTP(rr, req)

    spans := exporter.GetSpans()
    if len(spans) != 1 {
        t.Fatalf("expected 1 span, got %d", len(spans))
    }
    if spans[0].Parent().SpanID().String() != "00f067aa0ba902b7" {
        t.Error("parent span ID not propagated correctly")
    }
}

Running such tests in CI gives confidence that a future refactor won’t break tracing.

Key Takeaways

  • TraceContext is the lingua franca for distributed tracing; always configure your SDKs to use the W3C propagator.
  • Instrument entry points first (HTTP servers, message consumers) and then propagate via instrumented clients (HTTP, gRPC, Kafka, Redis).
  • Deploy a central OpenTelemetry Collector (or sidecar) to handle buffering, tail‑sampling, and exporter configuration uniformly.
  • Leverage tail sampling to keep all error traces while sampling successful ones, dramatically reducing storage costs.
  • Add business‑level baggage (e.g., request IDs) to enable correlation across logs, metrics, and traces.
  • Test propagation early with in‑memory exporters and integration tests against a real collector.

Further Reading