Lesson 3 of 20 14 minLeadership Track

Distributed Tracing with OpenTelemetry: End-to-End Observability

Implement distributed tracing across microservices using OpenTelemetry, Jaeger, and Spring Boot. Learn trace context propagation, span correlation, and production observability patterns.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

Key Takeaways

  • All HTTP requests/responses (RestTemplate, WebClient, @RestController)
  • All database calls (Spring Data, JDBC)
  • All Kafka producer/consumer operations

Premium outcome

Reliability, failure handling, and judgment for high-stakes systems.

Senior and staff engineers leading architecture, incident response, and critical platform decisions.

You leave with

  • Playbooks for resilience, graceful degradation, multi-region design, and incident thinking
  • Sharper language for communicating risk, trade-offs, and platform constraints
  • A more complete sense of how senior engineers think beyond feature delivery

A single client request enters your system, touches $8$ downstream microservices, queries $3$ databases, and takes $3.2$ seconds to return. Which service, database call, or network hop caused the delay?

Without distributed tracing, diagnosing this is a nightmare: engineers must manually search through $8$ different log files, trying to correlate timestamps across unsynchronized system clocks. With Distributed Tracing, a single identifier maps the entire lifecycle of the request. By visualizing this query as a unified execution graph, the bottleneck is isolated in seconds.

In this guide, we layout the architectural specifications, context propagation protocols, and production-ready implementations of distributed tracing using the industry-standard OpenTelemetry (OTel) framework.


System Requirements and Goals

To build a tracing framework capable of operating at high-scale without degrading core business workloads, we must design against the following requirements:

1. Functional Requirements

  • Parent-Child Span Relationships: The trace must represent hierarchical, causal execution boundaries (e.g., microservice billing transactions calling payment gateways must exist as a child span under the parent checkout span).
  • Context Propagation: The tracing context must transmit across network and protocol boundaries, including HTTP/REST, gRPC, and asynchronous event streams like Apache Kafka or RabbitMQ.
  • Metadata Tagging: Support attaching custom dynamic attributes (e.g., customer.id, order.total_amount) and discrete runtime events (e.g., cache_miss, retry_initiated) to individual execution blocks.

2. Non-Functional Requirements

  • Zero Request Path Blocking: Telemetry generation and network transmission must be fully asynchronous. A trace push failure must never block or slow down primary customer transactions.
  • Low Ingestion Overhead: Under peak loads, tracing instrumentation must consume less than $1%$ of service CPU cores.
  • High-Throughput Compression: Telemetry agents must support local memory batching and gzip compression before sending data over the network to OTel Collector hubs.

High-Level Design Architecture

Distributed tracing relies on propagating a single Trace ID across network hops. The sequence diagram below shows how an incoming request gains a context parent-child tree as it flows through an API Gateway, an Order Microservice, a Database, and an asynchronous Kafka Message Queue.

sequenceDiagram
    autonumber
    actor Client
    participant GW as API Gateway
    participant Order as Order Service
    participant DB as Postgres DB
    participant Kafka as Apache Kafka
    participant Bill as Billing Service

    Client->>GW: HTTP POST /checkout
    Note over GW: Generates TraceID: 8a4b2c...<br/>Generates SpanID: 1111 (Root Span)
    
    GW->>Order: HTTP POST /orders [Headers: traceparent=00-8a4b2c-1111-01]
    Note over Order: Extracts context.<br/>Starts Child SpanID: 2222 (Order Context)
    
    Order->>DB: SQL SELECT FOR UPDATE
    Note over DB: Driver measures execution latency.<br/>Records DB SpanID: 3333 under parent 2222
    DB-->>Order: OK
    
    Order->>Kafka: Publish "order.created" event [Kafka Headers: traceparent=00-8a4b2c-2222-01]
    Note over Order: Ends SpanID: 2222
    Order-->>GW: HTTP 201 Created
    GW-->>Client: Checkout Processing
    Note over GW: Ends Root SpanID: 1111
    
    %% Asynchronous Path
    Note over Kafka: Event sits in queue. Context preserved in headers.
    Kafka->>Bill: Consume "order.created"
    Note over Bill: Extracts context from Kafka headers.<br/>Starts Async SpanID: 4444 under parent 2222
    Note over Bill: Processes payment billing.
    Note over Bill: Ends SpanID: 4444

Telemetry Storage Pipeline Topology

To process millions of traces hourly, workload instances push their telemetry records through localized sidecars up to a centralized collection and visualization layer:

graph TD
    %% Define Nodes
    subgraph "Workload Container Pods"
        App1[Order Pod A] -->|gRPC Localhost| Sidecar1[OTel Sidecar Agent]
        App2[Order Pod B] -->|gRPC Localhost| Sidecar2[OTel Sidecar Agent]
    end

    subgraph "Central Observability VPC"
        Sidecar1 -->|OTLP gRPC Compressed| NLB[Network Load Balancer]
        Sidecar2 -->|OTLP gRPC Compressed| NLB
        NLB --> GW1[OTel Collector Gateway Node 1]
        NLB --> GW2[OTel Collector Gateway Node 2]
    end

    subgraph "Storage & Presentation Tier"
        GW1 -->|Index & Store| Tempo[(Grafana Tempo / Jaeger)]
        GW2 -->|Index & Store| Tempo
        Grafana[Grafana UI Engine] -->|Query Traces| Tempo
    end

    %% Styling
    classDef pod fill:#34495e,stroke:#fff,stroke-width:1px,color:#fff;
    classDef collector fill:#e67e22,stroke:#fff,stroke-width:2px,color:#fff;
    classDef storage fill:#27ae60,stroke:#fff,stroke-width:1px,color:#fff;
    
    class App1,App2,Sidecar1,Sidecar2 pod;
    class NLB,GW1,GW2 collector;
    class Tempo,Grafana storage;

A Visual Tracing Waterfall Chart

The visualization dashboard maps traces in a Gantt-like waterfall format to highlight where latency is bottlenecked:

gantt
    title Distributed Trace Execution Waterfall (Trace ID: 8a4b2c)
    dateFormat  X
    axisFormat %s ms

    section API Gateway
    Root Gateway Span [SpanID: 1111]           :active, 0, 3100
    section Order Service
    Create Order Handler [SpanID: 2222]        : 10, 800
    section Database
    Fetch Customer Account [SpanID: 3333]      : 20, 150
    section Payments
    Charge Card Processing [SpanID: 4444]      : 810, 3090
    section External Provider
    Stripe API TCP Handshake [SpanID: 5555]    : 850, 3080

API Design and Interface Contracts

Operationalizing tracing requires standardized context headers to guarantee inter-service compatibility.

1. W3C Trace Context Specification

OpenTelemetry standardizes on the W3C traceparent HTTP and messaging header, which consists of four fields separated by hyphens:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
  • Version (00): Indicates the W3C tracing specification version (currently 00).
  • Trace ID (4bf92f3577b34da6a3ce929d0e0e4736): $16$-byte unique hexadecimal identifier for the end-to-end request path.
  • Parent Span ID (00f067aa0ba902b7): $8$-byte unique hexadecimal identifier representing the upstream operation.
  • Trace Flags (01): $8$-bit field. 01 indicates the request is sampled (will be recorded to storage). 00 means not sampled.

2. OpenTelemetry Protocol (OTLP) Protobuf Schema

Below is the structural Protobuf contract representing trace data transferred via gRPC between the application agent and the collector:

syntax = "proto3";

package opentelemetry.proto.collector.trace.v1;

message ExportTraceServiceRequest {
  repeated ResourceSpans resource_spans = 1;
}

message ResourceSpans {
  Resource resource = 1;
  repeated ScopeSpans scope_spans = 2;
  string schema_url = 3;
}

message ScopeSpans {
  InstrumentationScope scope = 1;
  repeated Span spans = 2;
  string schema_url = 3;
}

message Span {
  bytes trace_id = 1;          // 16-byte hex array
  bytes span_id = 2;           // 8-byte hex array
  string name = 3;             // Span name e.g., "GET /checkout"
  int64 start_time_unix_nano = 4;
  int64 end_time_unix_nano = 5;
  repeated KeyValue attributes = 6;
  repeated Event events = 7;
  Status status = 8;
}

message KeyValue {
  string key = 1;
  AnyValue value = 2;
}

message AnyValue {
  oneof value {
    string string_value = 1;
    int64 int_value = 2;
    double double_value = 3;
    bool bool_value = 4;
  }
}

Low-Level Design & Component Mechanics

To capture business logic flows programmatically, we implement custom tracing instrumentation blocks using JVM ecosystems.

Spring Boot 3 & OpenTelemetry SDK Implementation

The following Java component demonstrates how to leverage the manual OpenTelemetry API in Spring Boot 3 to initialize custom span graphs, populate tracing tags, insert trace events, and handle asynchronous message header extractions cleanly.

package com.codesprintpro.tracing;

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.context.Scope;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.propagation.TextMapPropagator;
import io.opentelemetry.context.propagation.TextMapSetter;
import io.opentelemetry.context.propagation.TextMapGetter;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.header.Headers;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;

@Service
public class OrderTracingService {

    private Tracer tracer;
    private TextMapPropagator propagator;

    @PostConstruct
    public void init() {
        // Retrieve standard OTel tracers
        this.tracer = GlobalOpenTelemetry.getTracer("com.codesprintpro.order", "2.1.0");
        this.propagator = GlobalOpenTelemetry.getPropagators().getTextMapPropagator();
    }

    public void executeCheckout(String customerId, double cartTotal) {
        // 1. Initialize a custom Parent Span representing the operation
        Span parentSpan = tracer.spanBuilder("CheckoutProcessor")
                .setAttribute("customer.id", customerId)
                .setAttribute("cart.total", cartTotal)
                .startSpan();

        // 2. Put the span into active ThreadLocal Scope
        try (Scope scope = parentSpan.makeCurrent()) {
            
            // Execute simulated database lookup inside a manual child span
            validateInventoryWithChildSpan();
            
            // Execute async payment call and inject context into Kafka
            publishOrderEvent(customerId, cartTotal);

            parentSpan.setStatus(StatusCode.OK, "Checkout process completed successfully");
            parentSpan.addEvent("checkout_finished_successfully");

        } catch (Exception e) {
            parentSpan.recordException(e);
            parentSpan.setStatus(StatusCode.ERROR, "Checkout failed: " + e.getMessage());
            throw e;
        } finally {
            // 3. Spans MUST be ended to flush telemetry payload
            parentSpan.end();
        }
    }

    private void validateInventoryWithChildSpan() {
        Span childSpan = tracer.spanBuilder("ValidateInventorySubTask")
                .startSpan();
        
        try (Scope scope = childSpan.makeCurrent()) {
            // Simulated validation delay
            Thread.sleep(80);
            childSpan.setStatus(StatusCode.OK);
        } catch (InterruptedException e) {
            childSpan.setStatus(StatusCode.ERROR, e.getMessage());
        } finally {
            childSpan.end();
        }
    }

    private void publishOrderEvent(String customerId, double total) {
        Span kafkaSpan = tracer.spanBuilder("KafkaPublishEvent")
                .setAttribute("messaging.system", "kafka")
                .setAttribute("messaging.destination", "order-created")
                .startSpan();

        try (Scope scope = kafkaSpan.makeCurrent()) {
            ProducerRecord<String, String> record = new ProducerRecord<>("order-created", "order-payload");

            // Inject trace context headers into the Kafka ProducerRecord
            propagator.inject(Context.current(), record.headers(), new TextMapSetter<Headers>() {
                @Override
                public void set(Headers carrier, String key, String value) {
                    if (carrier != null) {
                        carrier.add(key, value.getBytes(StandardCharsets.UTF_8));
                    }
                }
            });

            // Simulate actual Kafka client sending payload
            kafkaSpan.setStatus(StatusCode.OK);
        } finally {
            kafkaSpan.end();
        }
    }

    // Consumer side: extracting trace context from Kafka headers
    public void consumeOrderEvent(Headers headers) {
        // Extract context from incoming payload headers
        Context extractedContext = propagator.extract(Context.current(), headers, new TextMapGetter<Headers>() {
            @Override
            public Iterable<String> keys(Headers carrier) {
                return () -> java.util.Arrays.stream(carrier.toArray())
                        .map(h -> h.key())
                        .iterator();
            }

            @Override
            public String get(Headers carrier, String key) {
                if (carrier != null && carrier.lastHeader(key) != null) {
                    return new String(carrier.lastHeader(key).value(), StandardCharsets.UTF_8);
                }
                return null;
            }
        });

        // Start child span from the extracted remote trace context
        Span consumerSpan = tracer.spanBuilder("KafkaConsumeEvent")
                .setParent(extractedContext)
                .setAttribute("messaging.operation", "process")
                .startSpan();

        try (Scope scope = consumerSpan.makeCurrent()) {
            // Process the business logic
            consumerSpan.setStatus(StatusCode.OK);
        } finally {
            consumerSpan.end();
        }
    }
}

Scaling Challenges & Production Bottlenecks

1. Telemetry Ingestion Overwhelm at Scale

At $100,000$ requests per second, distributed tracing data volume is immense. If every trace is logged and stored, tracing infrastructure costs will rapidly dwarf the production application hosting costs.

Back-of-the-Envelope Estimation:

  • System Metrics:
    • Request Rate: $100,000$ transactions/sec.
    • Average Span Tree Size: $8$ spans per request transaction.
    • Raw Size per Span: $750$ bytes of structured tags and stack traces.
  • Telemetry Volume Calculation: $$\text{Spans per Second} = 100,000 \times 8 = 800,000\text{ spans/sec}$$ $$\text{Throughput} = 800,000 \times 750\text{ bytes} = 600,000,000\text{ bytes/sec} = 600\text{ MB/s}$$ $$\text{Throughput per Hour} = 600\text{ MB/s} \times 3,600\text{ sec} = 2,160,000\text{ MB} \approx 2.16\text{ TB/hour}$$ $$\text{Throughput per Day} = 2.16\text{ TB} \times 24 \approx 51.84\text{ Terabytes per Day}$$
  • Impact: Indexing $51\text{ Terabytes}$ of daily JSON payload demands millions of dollars in storage engine provisioning (Tempo/Elasticsearch).

2. Operational Mitigation Strategies

  • Head-Based Probabilistic Sampling: Decide immediately when the request hits the API Gateway. Let $99%$ of requests pass without tracing, and trace only $1%$ of total system operations. $$\text{New Daily Storage} = 51.84\text{ TB} \times 0.01 \approx 518.4\text{ GB per Day}$$
  • Tail-Based Adaptive Sampling: Deploy OTel Collector gateways to hold active spans in temporary memory buffers. If the trace records an HTTP $5\text{xx}$ error code, a circuit breaker trip event, or its total execution latency exceeds $2000\text{ ms}$, save $100%$ of that trace. If it is a standard $200\text{ OK}$ fast transaction, discard it. This ensures zero data loss of critical errors while reducing storage ingestion costs by over $90%$.

Technical Trade-offs & Strategic Compromises

Designing a distributed system tracing fabric requires managing trade-offs between architectural isolation, instrumentation costs, and precision debugging capabilities.

Tracing Design Choice Pros Cons Performance & Cost Matrix
Auto-Instrumentation (Javaagent CLI attachments) * Instant operational launch; zero code modifications required.
* Out-of-the-box support for database, logging, and HTTP library wrapping.
* Imposes higher memory overhead due to classloader byte-code manipulation.
* Automatically instruments non-critical dependencies, bloating metrics tags.
* Development Cost: Low
* Memory Overhead: High
Manual Instrumentation (Programmatic API Integration) * Absolute precision. Only trace critical business boundaries.
* Minimal JVM memory footprints; highly optimized code paths.
* Demands significant development and maintenance hours.
* Risk of developers failing to catch exceptions or close spans in code loops.
* Development Cost: High
* Memory Overhead: Minimal
Prometheus Metrics Scrapes (Pull telemetry pattern) * Scraper engine controls scrape volume.
* Target instances are highly decoupled; metrics failures never cascade.
* Unable to isolate granular individual user-transaction pathways. * Resolution Limit: 10s-30s intervals
* Storage Cost: Low
OpenTelemetry Spans (Push telemetry pattern) * Maps exact, millisecond-precise execution waterfalls of single transactions. * Higher bandwidth overhead. Slower backends can queue traces in application memory buffers. * Resolution Limit: Real-Time / Millisecond
* Storage Cost: Extremely High

Failure Scenarios and Fault Tolerance

1. Tracing Buffer Saturation (Backpressure Outage)

When network latency between microservices and OTel Collector gateways spikes, trace buffers inside application memories fill up. If using synchronous exporters or failing to configure buffer limit size bounds, applications will suffer memory leaks and Out-of-Memory (OOM) crash loops.

Fault-Tolerance Mitigation:

  1. Configure non-blocking async exporters utilizing the OTel BatchSpanProcessor with an explicit maxQueueSize (e.g., $2048$ spans).
  2. Set a drop policy (drop_on_queue_full). If the memory queue saturates, discard new trace spans immediately, safeguarding application runtime stability at the cost of transient telemetry logs.

2. Multi-Region Clock Drift (Nonsensical Spans)

In massive multi-region systems, system clocks on cloud virtual machines will naturally drift by tens of milliseconds. In distributed waterfalls, if a downstream Billing instance executes a transaction, and its VM clock lags $50\text{ ms}$ behind the upstream API Gateway instance, the trace visualizer will render a negative span duration (i.e. child span starting before its parent span).

Fault-Tolerance Mitigation:

  1. Enforce strict Network Time Protocol (NTP) synchronization across all cloud nodes using cloud time sync daemons.
  2. Utilize tracing visualizers that incorporate parent-child causal alignment algorithms, adjusting child offset variables based on topological sequence order to normalize negative time spans automatically.

Staff Engineer Perspective

// Safe MDC clearing pattern inside custom execution filters
try {
    MDC.put("traceId", span.getSpanContext().getTraceId());
    MDC.put("spanId", span.getSpanContext().getSpanId());
    filterChain.doFilter(request, response);
} finally {
    // Crucial to prevent thread local memory leaks in shared thread pools
    MDC.clear();
}

Verbal Script & Mock Interview

Verbal Script: Context Propagation Mechanics

Interviewer: "Can you explain how context propagation works in distributed tracing? Walk me through what happens under the hood when a transaction spans HTTP microservices and an asynchronous Kafka queue."

Candidate: "At a high level, Context Propagation is the mechanism that allows distributed tracing to reconstruct a unified execution path across network and thread boundaries. It consists of three mechanical phases: Injection, Transmission, and Extraction.

First, when a request hits our API Gateway, the OpenTelemetry SDK generates a unique $16$-byte Trace ID and an $8$-byte Span ID representing the root operation. These variables are stored in the active thread's Context, which is typically managed as a ThreadLocal storage.

Second, when the Gateway makes a downstream HTTP call to the Order Microservice, a component called a TextMapPropagator inspects the current active Context. It serializes this context into the standardized W3C traceparent HTTP header, consisting of the version, trace ID, parent span ID, and sampling flag.

Third, when the downstream Order Microservice receives the HTTP request, its OTel middleware interceptor acts as the extractor. It parses the incoming traceparent header, extracts the Trace ID and Parent Span ID, and registers them in the Order Service's ThreadLocal context. Any child span started inside the Order Service automatically inherits this Trace ID, setting the Gateway's Span ID as its parent.

When we bridge this execution flow over an asynchronous channel like an Apache Kafka queue, the process remains identical but adapts to the protocol:

  1. The Kafka Producer intercepts the outgoing event record.
  2. The TextMapPropagator serializes the active trace Context and injects it as binary metadata bytes directly into the Kafka Message Headers (using the traceparent key).
  3. The message is published and sits in the Kafka partition. The trace is now paused in time, yet the context is safely preserved within the broker.
  4. When the Billing Service consumer pulls the event record, the consumer interceptor extracts the binary headers, deserializes the W3C trace parent metadata, and spins up a new child span. This span is mapped under the original Trace ID, successfully connecting the asynchronous worker execution directly to the client's synchronous checkout request waterfall chart."

Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.