Using OpenTelemetry W3C TraceContext to Keep Distributed Traces Whole
Learn how to enable OpenTelemetry’s W3C TraceContext propagator so trace IDs flow unchanged between services, giving you whole traces in Jaeger or Tempo.
20 Aug 2025, 02:28 UTC

Problem: trace context gets lost between services
When one service instruments with OpenTelemetry and another uses a different format (e.g., Zipkin B3 or Jaeger’s native header), the traceparent/tracestate headers are not understood. The downstream service starts a new trace instead of continuing the parent one, leaving you with fragmented views in Jaeger, Tempo, or any backend.
Thesis: the W3C TraceContext propagator restores end‑to‑end trace continuity
OpenTelemetry ships a propagator that reads and writes the standardized traceparent and tracestate headers defined by the W3C TraceContext specification. Enabling it on both client and server lets any OpenTelemetry‑instrumented hop carry the same trace ID across process boundaries, giving you a single, linked trace graph.
Configuration: enable the W3C propagator in a Java SDK
Add the OpenTelemetry API, SDK, and the W3C propagator (included in the SDK) to your Maven or Gradle build. Then configure the SDK globally or per‑tracer.
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.exporter.jaeger.JaegerGrpcSpanExporter;
public class OtelConfig {
public static OpenTelemetry init() {
// Jaeger exporter – adjust endpoint as needed
JaegerGrpcSpanExporter jaegerExporter = JaegerGrpcSpanExporter.builder()
.setEndpoint("http://jaeger-collector:14250")
.build();
SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(BatchSpanProcessor.builder(jaegerExporter).build())
.setSampler(io.opentelemetry.sdk.trace.samplers.Sampler.alwaysOn())
.build();
// The SDK automatically registers the W3C TraceContext propagator
return OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.buildAndRegisterGlobal();
}
}
Place the call to OtelConfig.init() early in your application’s startup (e.g., in a main method or Spring @PostConstruct). No extra dependencies are required beyond the core OpenTelemetry SDK.
Worked example: client HttpClient → Jetty server
Below is a minimal client that makes an outgoing HTTP request and a Jetty server that receives it. Both sides use the same OpenTelemetry configuration shown above.
Client side
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class ClientApp {
private static final Tracer tracer = OpenTelemetry.getGlobal().getTracer("client");
public static void main(String[] args) throws Exception {
try (Scope scope = tracer.spanBuilder("client-call").startSpan().makeCurrent()) {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://localhost:8080/hello"))
.GET()
.build();
// OpenTelemetry instrumentation automatically injects traceparent
HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
}
}
}
Server side (Jetty)
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AbstractHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ServerApp {
private static final Tracer tracer = OpenTelemetry.getGlobal().getTracer("server");
public static void main(String[] args) throws Exception {
Server server = new Server(8080);
server.setHandler(new AbstractHandler() {
@Override
public void handle(String target, org.eclipse.jetty.server.Request baseRequest,
HttpServletRequest request, HttpServletResponse response)
throws IOException {
try (Scope scope = tracer.spanBuilder("server-handler").startSpan().makeCurrent()) {
response.setContentType("text/plain;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("hello");
}
baseRequest.setHandled(true);
}
});
server.start();
server.join();
}
}
When you run the client, the OpenTelemetry SDK will add a header similar to:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
The Jetty server reads that header, extracts the trace ID and parent ID, and starts a child span. In Jaeger you will see two spans linked by the same trace ID.
Verification: practical ways to confirm the propagator works
- Header inspection: Run the client with
curl -vor a proxy like mitmproxy and look for thetraceparentline in the request. - Backend view: In Jaeger (or Tempo, Zipkin), open the trace and verify that the server span shows a “References” → “ChildOf” link to the client span, sharing the trace ID.
- SDK debug logs: Start the JVM with
OTEL_DEBUG=propagators. You should see lines such asInjecting context into carrierandExtracting context from carriermentioning theW3CTraceContextPropagator.
Trade‑off / limitation: header size and legacy compatibility
The W3C TraceContext header adds a few bytes (typically traceparent: plus a 55‑character value) to every HTTP request. This is negligible for most services but may matter in ultra‑low‑latency or bandwidth‑constrained environments.
More importantly, any hop that does not understand W3C TraceContext will ignore the header and start a new trace. If you have legacy services that only emit or consume Zipkin B3, you have two options:
- Deploy a bridging propagator (OpenTelemetry provides a
B3Propagator) alongside the W3C propagator, ordering them so the W3C propagator runs first. - Use a dual‑header approach: configure the SDK to inject both
traceparentand the legacy header, then rely on the legacy service’s existing instrumentation to read the older format.
Either approach increases processing overhead slightly and requires careful ordering to avoid header conflicts.
Actionable closing
- Add the OpenTelemetry SDK to your service and ensure the global tracer provider is initialized early.
- Verify that the W3C propagator is active (debug logs or header check).
- Run a simple client‑server test as shown; confirm the linked spans appear in your backend.
- If you encounter legacy systems, decide whether to add a bridging propagator or dual‑header injection, and test the change in a staging environment.
By standardizing on W3C TraceContext you regain a complete, end‑to‑end view of distributed traces without being locked into a single vendor’s format.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.