Chapter 39: Distributed Tracing

Context Propagation, Sampling Strategies, and Trace Analysis in Microservices

1. Why Distributed Tracing

The Microservices Debugging Problem

In monolithic applications, debugging is straightforward: add logs, set breakpoints, follow the stack trace. In distributed systems, a single user request flows through dozens of services across multiple data centers. Traditional debugging breaks down:

  • No single stack trace: Request spans 10+ services, each with its own logs
  • Latency attribution: Total response time is 3 seconds. Which service is slow?
  • Error correlation: Service D returns 500. Was it caused by Service B's timeout or Service C's invalid data?
  • Cascading failures: One slow service causes timeouts in 5 others. Which is the root cause?
Real-World Pain
Without distributed tracing: "The checkout page is slow" leads to engineers SSH-ing into 8 services, grepping logs for request IDs, correlating timestamps manually across time zones, and still not finding the bottleneck. With tracing: One trace ID shows the exact request flow and identifies the slow database query in 30 seconds.

What Distributed Tracing Provides

Before Tracing

  • Grep logs across 10+ services
  • Manually correlate timestamps
  • Guess at service dependencies
  • No latency breakdown by operation
  • Can't identify critical path
  • Days to debug production issues

After Tracing

  • Single trace ID shows full request flow
  • Waterfall diagram with exact timing
  • Auto-generated service dependency map
  • Latency attributed to each span
  • Critical path analysis highlights bottlenecks
  • Minutes to root cause production issues

Request Flows Across Services

Consider an e-commerce checkout request:

Request Flow Without Tracing
API Gateway → Frontend Service → Auth Service → Order Service → Inventory Service → Payment Service → Email Service

Problem: Response took 3.2s. Which service was slow?
Each service logs: "Request completed in 145ms" but doesn't track upstream latency.
No visibility into parallel calls, retries, or queue wait times.
Request Flow With Tracing
gantt title Trace Waterfall (Total: 3.2s) dateFormat X axisFormat %L section API Gateway receive request :0, 50 section Frontend process :50, 150 section Auth verify token :150, 300 section Order Service create order :300, 800 section Inventory check stock (parallel) :800, 1200 section Payment charge card (parallel) :800, 2800 section Email send confirmation :2800, 3200

Insight: Payment Service took 2.0s (62% of total latency). That's the bottleneck.

Interview Question
Q: How does distributed tracing differ from application logs?

A: Logs are per-service, unstructured, and lack request context. Tracing provides: (1) Request-scoped context that flows across services, (2) Parent-child relationships between operations, (3) Timing data for latency analysis, (4) Structured metadata (HTTP status, error flags), (5) Visual waterfall diagrams. Logs answer "what happened in this service." Traces answer "what happened to this request across all services."

Latency Attribution

Distributed tracing breaks down response time into components:

  • Service time: Actual work done by each service
  • Network time: Transmission between services
  • Queue time: Waiting in message queues, thread pools
  • Database time: Query execution, connection pool waits
  • External API time: Third-party service calls

This attribution enables targeted optimization: if 80% of latency is database queries, focus there instead of optimizing application code.

2. Core Concepts

Trace

A trace represents the complete journey of a request through the system. It has a unique trace_id (128-bit or 64-bit) that is assigned at the entry point (API gateway, load balancer) and propagated to all downstream services.

Trace ID Example
trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
Duration: 3.245s
Root span: GET /api/checkout
Child spans: 24

Span

A span represents a single operation within a trace. Each span has:

  • span_id: Unique identifier for this operation (64-bit)
  • parent_span_id: Link to the calling operation (null for root span)
  • operation name: Descriptive name (e.g., "db.query", "http.request")
  • start time & duration: Timing data
  • attributes/tags: Key-value metadata (HTTP status, error flag, user ID)
  • events/logs: Timestamped annotations within the span
{
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "parent_span_id": "8e3c31d5baad7b8a",
  "operation_name": "payment.charge",
  "start_time": "2026-05-12T10:30:42.123Z",
  "duration_ms": 2045,
  "attributes": {
    "http.method": "POST",
    "http.status_code": 200,
    "payment.amount": 49.99,
    "payment.gateway": "stripe",
    "db.statement": "INSERT INTO payments..."
  },
  "events": [
    {"timestamp": "2026-05-12T10:30:43.500Z", "name": "gateway_request_sent"},
    {"timestamp": "2026-05-12T10:30:44.100Z", "name": "gateway_response_received"}
  ]
}

Span Context

Span context is the minimal data needed to link spans across process boundaries. It contains:

  • trace_id: Identifies the trace
  • span_id: Identifies the current span (becomes parent_span_id in child spans)
  • trace_flags: Sampling decision, debug flag
  • trace_state: Vendor-specific context (optional)

Span context is propagated via HTTP headers, message metadata, or RPC frameworks.

Parent-Child Relationships

Spans form a tree structure. The root span represents the entry point. Child spans represent nested operations:

Span Hierarchy
graph TD A["GET /api/checkout
(root span)"] --> B["auth.verify
(150ms)"] A --> C["order.create
(2800ms)"] C --> D["inventory.check
(400ms)"] C --> E["payment.charge
(2045ms)"] E --> F["db.query: INSERT payments
(35ms)"] E --> G["stripe.api.call
(1980ms)"] C --> H["email.send
(220ms)"] style A fill:#60a5fa style C fill:#34d399 style E fill:#f87171 style G fill:#fb923c

The critical path (longest execution path) is A → C → E → G, totaling 2.8s. Optimizing other spans won't improve overall latency.

Baggage

Baggage is key-value data propagated across the entire trace. Unlike span attributes (stored per-span), baggage is inherited by all descendant spans. Use cases:

  • User context: user_id, tenant_id, session_id
  • Feature flags: Propagate experiment variants
  • Request metadata: client_version, device_type
  • Security context: auth scopes, permissions
Warning
Baggage is propagated with every request. Keep it small (under 1 KB). Large baggage adds latency and bandwidth overhead. Never put sensitive data (PII, credentials) in baggage without encryption.

Trace Waterfall Diagram

The canonical visualization for traces is a waterfall diagram, showing spans on a timeline:

Waterfall Visualization
Time (ms)    0        500      1000     1500     2000     2500     3000
             |--------|--------|--------|--------|--------|--------|
GET /checkout|================================================| 3245ms
  auth.verify|===|                                              150ms
  order.create    |==========================================| 2800ms
    inventory.check    |====|                                  400ms
    payment.charge     |================================|     2045ms
      db.query              |=|                                 35ms
      stripe.api                |=======================|     1980ms
    email.send                                    |==|          220ms
                    

Analysis: Stripe API call is the bottleneck (61% of total time). Inventory check and email send run in parallel with payment, so optimizing them won't help.

3. Context Propagation

The Cross-Process Challenge

Traces span multiple processes, hosts, and languages. How does Service B know it's part of the same trace as Service A? Context propagation serializes span context and transmits it across boundaries via:

  • HTTP headers: Most common for REST/GraphQL APIs
  • Message metadata: Kafka headers, SQS attributes, RabbitMQ properties
  • gRPC metadata: Key-value pairs in gRPC requests
  • Function parameters: For in-process propagation

W3C TraceContext Standard

The W3C TraceContext specification defines two HTTP headers for context propagation:

traceparent Header

Encodes trace_id, span_id, and trace flags in a compact format:

Traceparent Format
traceparent: version-trace_id-span_id-trace_flags

Example:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

00 = version 0 (current)
4bf92f3577b34da6a3ce929d0e0e4736 = trace_id (128-bit hex)
00f067aa0ba902b7 = parent_span_id (64-bit hex)
01 = sampled (00 = not sampled, 01 = sampled)

tracestate Header

Optional vendor-specific context. Multiple vendors can append their data:

tracestate: aws=s:1;r:5;k:abc123,datadog=parent_id=123456789

This allows multiple tracing systems to coexist without interfering.

B3 Headers (Zipkin)

The older B3 propagation format uses separate headers:

X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736
X-B3-SpanId: 00f067aa0ba902b7
X-B3-ParentSpanId: 8e3c31d5baad7b8a
X-B3-Sampled: 1

Or single-header format:

b3: 4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1-8e3c31d5baad7b8a

W3C TraceContext is preferred for new systems, but B3 is still widely used (Twitter, Uber, Netflix).

Injectors and Extractors

Tracing libraries provide injectors (write context to headers) and extractors (read context from headers):

// Injector: Service A sends request to Service B
const span = tracer.startSpan('http.request');
const headers = {};
tracer.inject(span.context(), FORMAT_HTTP_HEADERS, headers);

// headers now contains:
// { 'traceparent': '00-4bf92f...', ... }

fetch('https://service-b/api', { headers });

// Extractor: Service B receives request
const parentContext = tracer.extract(FORMAT_HTTP_HEADERS, request.headers);
const childSpan = tracer.startSpan('process.request', { childOf: parentContext });

Cross-Process Propagation

Context propagation happens at every service boundary:

Context Propagation Sequence
sequenceDiagram participant Client participant API Gateway participant Service A participant Service B participant DB Client->>API Gateway: HTTP Request Note over API Gateway: Generate trace_id: abc123
span_id: 001 API Gateway->>Service A: traceparent: 00-abc123-001-01 Note over Service A: Extract context
Create child span_id: 002 Service A->>Service B: traceparent: 00-abc123-002-01 Note over Service B: Extract context
Create child span_id: 003 Service B->>DB: Add trace_id to logs DB-->>Service B: Query result Service B-->>Service A: Response (span 003 ends) Service A-->>API Gateway: Response (span 002 ends) API Gateway-->>Client: Response (span 001 ends) Note over API Gateway: Send complete trace
to tracing backend
Interview Question
Q: What happens if a service doesn't propagate trace context?

A: The trace is broken at that point. Downstream services start a new trace with a new trace_id, losing the connection to the original request. This is called "trace fragmentation." You'll see two disconnected traces instead of one complete flow. Defense: (1) Auto-instrumentation that propagates context automatically, (2) Canary tests that verify context propagation, (3) Missing parent span alerts.

Async and Queue Propagation

Context propagation is tricky for message queues and async tasks because there's no HTTP request/response:

  • Kafka: Store traceparent in message headers
  • SQS: Store traceparent in message attributes
  • RabbitMQ: Store traceparent in message properties
  • Background jobs: Serialize span context into job parameters
// Producer: Inject context into Kafka message
const span = tracer.startSpan('kafka.produce');
const headers = {};
tracer.inject(span.context(), FORMAT_HTTP_HEADERS, headers);

await producer.send({
  topic: 'orders',
  messages: [{ value: orderData, headers }]
});

// Consumer: Extract context from Kafka message
const message = await consumer.consume();
const parentContext = tracer.extract(FORMAT_HTTP_HEADERS, message.headers);
const span = tracer.startSpan('kafka.consume', { childOf: parentContext });

4. Instrumentation

Auto-Instrumentation

Auto-instrumentation uses language agents to automatically inject tracing code into common libraries (HTTP clients, database drivers, frameworks). No code changes required:

Java Agent

java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.service.name=order-service \
     -Dotel.exporter.otlp.endpoint=http://collector:4318 \
     -jar app.jar

Automatically instruments: Spring Boot, JDBC, Hibernate, Kafka, Redis, HTTP clients, etc.

Python Auto-Instrumentation

pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
opentelemetry-instrument python app.py

Automatically instruments: Flask, Django, requests, psycopg2, pymongo, etc.

Manual Instrumentation

For custom logic, business operations, or languages without auto-instrumentation:

// Node.js with OpenTelemetry
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('payment-service');

async function processPayment(orderId, amount) {
  const span = tracer.startSpan('payment.process', {
    attributes: {
      'order.id': orderId,
      'payment.amount': amount,
      'payment.currency': 'USD'
    }
  });

  try {
    // Business logic
    const result = await chargeCard(amount);

    span.setAttribute('payment.transaction_id', result.txnId);
    span.setStatus({ code: SpanStatusCode.OK });

    return result;
  } catch (error) {
    span.setStatus({
      code: SpanStatusCode.ERROR,
      message: error.message
    });
    span.recordException(error);
    throw error;
  } finally {
    span.end();
  }
}

Library Instrumentation

Third-party libraries can provide built-in tracing support:

// Express.js middleware
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');

registerInstrumentations({
  instrumentations: [
    new ExpressInstrumentation({
      requestHook: (span, requestInfo) => {
        span.setAttribute('user.id', requestInfo.request.user?.id);
      }
    })
  ]
});

// Automatic span creation for all routes
app.get('/orders/:id', async (req, res) => {
  // Span automatically created and propagated
  const order = await db.query('SELECT * FROM orders WHERE id = ?', [req.params.id]);
  res.json(order);
});

OpenTelemetry SDK

OpenTelemetry is the industry-standard observability framework. It provides:

  • Vendor-neutral API: Write once, export to any backend (Jaeger, Zipkin, Tempo, Datadog, New Relic)
  • Auto-instrumentation: For popular frameworks/libraries
  • Context propagation: W3C TraceContext support
  • Multiple signals: Traces, metrics, logs (unified context)
  • Collector: Centralized agent for batching, sampling, routing
OpenTelemetry Architecture
graph LR A[Application] -->|Auto-instrument| B[OTel SDK] C[Manual Spans] --> B B -->|OTLP Protocol| D[OTel Collector] D -->|Export| E[Jaeger] D -->|Export| F[Tempo] D -->|Export| G[Zipkin] D -->|Export| H[Datadog] D -->|Sampling| D D -->|Batching| D style B fill:#60a5fa style D fill:#34d399
Key Insight
OpenTelemetry decouples instrumentation from backend. You can switch from Jaeger to Tempo without changing application code. The collector handles protocol translation, sampling, and batching, reducing load on applications and backends.

Instrumentation Best Practices

Practice Why Example
Instrument external calls Most latency comes from I/O HTTP, gRPC, database, Redis, S3
Instrument business logic Understand domain operations order.create, inventory.reserve
Add context attributes Enable filtering/aggregation user_id, tenant_id, error_code
Record errors Correlate errors with latency span.recordException(error)
Avoid over-instrumentation Too many spans hurt performance Don't trace every utility function

5. Sampling Strategies

Why Sampling?

At scale, tracing every request is expensive:

  • Volume: 10,000 req/sec × 20 spans/req × 2 KB/span = 400 MB/sec = 34 TB/day
  • Storage cost: Storing full traces for 30 days costs $50,000+/month
  • Processing overhead: Network, serialization, backend indexing
  • Query performance: Billions of spans slow down search

Sampling reduces cost by recording only a fraction of traces. The tradeoff: lower resolution but still sufficient for debugging and analysis.

Head-Based Sampling

Head-based sampling makes the sampling decision at trace creation (the "head" of the request). Once decided, the decision propagates to all services via trace_flags.

Probabilistic Sampling

Sample X% of traces randomly:

// Sample 1% of traces
const sampler = new TraceIdRatioBasedSampler(0.01);

const provider = new TracerProvider({
  sampler: sampler
});

Pros: Simple, predictable cost, unbiased sample
Cons: Rare errors might be missed, no adaptivity

Rate-Limiting Sampling

Sample up to N traces per second:

const sampler = new RateLimitingSampler(100); // Max 100 traces/sec

Pros: Predictable backend load
Cons: Biased toward low-traffic periods

Tail-Based Sampling

Tail-based sampling makes the decision after the trace completes. The backend buffers traces briefly, then decides which to keep based on criteria:

  • Error-based: Keep all traces with errors
  • Latency-based: Keep slow traces (p99, p999)
  • Attribute-based: Keep traces with specific user IDs, endpoints
  • Probabilistic fallback: Sample 1% of normal traces
Tail-Based Sampling Pipeline
graph LR A[Application] -->|All spans| B[Collector Buffer] B -->|Complete trace| C{Sampling Decision} C -->|Error?| D[Keep] C -->|Latency > p99?| D C -->|VIP user?| D C -->|Random 1%?| D C -->|Drop| E[Discard] D --> F[Storage Backend] style B fill:#fbbf24 style C fill:#60a5fa style D fill:#34d399 style E fill:#f87171

Pros: Keep all interesting traces (errors, slow requests), adaptive to traffic patterns
Cons: Requires buffering (memory/latency), needs centralized collector, complex implementation

Adaptive Sampling

Adaptive sampling adjusts sample rate based on system conditions:

  • Error rate: Increase sampling when error rate spikes
  • Latency: Increase sampling during latency incidents
  • Traffic volume: Decrease sampling during traffic spikes
  • Cost budget: Adjust to stay within budget limits
// Pseudo-code for adaptive sampling
if (errorRate > 0.05) {
  sampleRate = 0.10; // Sample 10% during incidents
} else if (p99Latency > SLA) {
  sampleRate = 0.05; // Sample 5% during slowdowns
} else {
  sampleRate = 0.01; // Sample 1% normally
}

Sampling Strategy Comparison

Strategy Decision Point Complexity Pros Cons
Head-based probabilistic Trace start Low Simple, fast, predictable Misses rare errors
Head-based rate-limit Trace start Low Predictable backend load Biased sampling
Tail-based error/latency Trace end High Keeps all interesting traces Requires buffering, latency
Adaptive Dynamic High Responds to incidents Complex tuning
Hybrid Both Medium Best of both worlds More moving parts
Interview Question
Q: You have 1 million requests/day. 0.1% have errors. With 1% sampling, how many error traces do you capture?

A: 1M × 0.001 (error rate) × 0.01 (sample rate) = 10 error traces/day. That's too few for debugging. Solution: Use tail-based sampling to keep all error traces (1000/day) + 1% of success traces (9,990/day) = 10,990 traces/day total. This captures all errors while staying within budget.

Recommended Strategy

For production systems, use a hybrid approach:

  1. Head-based: Sample 1-5% of traces probabilistically
  2. Tail-based: Always keep traces with:
    • HTTP 4xx/5xx status codes
    • Latency > p99 threshold
    • Spans with error flag set
  3. Adaptive: Increase head-based sampling during incidents (error spike, latency spike)

This ensures you always have error/slow traces while keeping costs predictable.

6. Storage & Backends

Storage Requirements

Tracing backends must handle:

  • High write throughput: Thousands of spans/sec
  • Time-series data: Spans arrive in trace order
  • Fast queries: Find trace by ID in milliseconds
  • Rich filtering: Query by service, operation, latency, attributes
  • Retention: Store 7-30 days, then delete

Jaeger

Jaeger is the most popular open-source tracing backend (originally from Uber).

Architecture

Jaeger Architecture
graph TD A[Application] -->|Spans| B[Jaeger Agent] B -->|Batch| C[Jaeger Collector] C -->|Write| D[Cassandra/ES] E[Jaeger Query] -->|Read| D F[UI] -->|HTTP| E G[Kafka Optional] -.->|Buffer| C style C fill:#60a5fa style D fill:#34d399 style E fill:#a78bfa
  • Agent: Sidecar that batches spans locally, reduces network calls
  • Collector: Receives spans, writes to storage
  • Storage: Cassandra (scalable) or Elasticsearch (rich queries)
  • Query: API for retrieving traces
  • UI: Web interface with waterfall diagrams, service maps

Pros: Mature, feature-rich UI, Cassandra scales to billions of spans
Cons: Operational complexity (run Cassandra/ES cluster), expensive storage, indexing overhead

Tempo

Grafana Tempo is a cost-efficient tracing backend designed for high volume.

Architecture

Tempo Architecture
graph TD A[Application] -->|Spans| B[Tempo Distributor] B -->|Write| C[Tempo Ingester] C -->|Buffer| C C -->|Flush| D[S3/GCS/Azure Blob] E[Tempo Querier] -->|Read| D F[Grafana] -->|HTTP| E E -->|Bloom Filter| G[Metadata Index] style C fill:#60a5fa style D fill:#34d399 style E fill:#a78bfa
  • Object storage: Stores traces as Parquet files in S3/GCS (cheap)
  • No indexing: Traces are only queryable by trace_id (not by attributes)
  • Bloom filters: Efficiently find traces without full indexing
  • Integration: Native Grafana integration, correlate with logs/metrics

Pros: 10x cheaper storage, simple ops (no database), scales to trillions of spans
Cons: Limited search (trace_id only), no attribute-based queries, must integrate with logs/metrics for discovery

Zipkin

Zipkin is the original distributed tracing system (from Twitter, 2012).

  • Storage: MySQL, Elasticsearch, or in-memory
  • Simpler: Single binary, easier to run than Jaeger
  • Less scalable: MySQL struggles at high volume
  • Legacy: Fewer features than Jaeger/Tempo

Use case: Small deployments, dev/test environments, quick setup

Backend Comparison

Backend Storage Query Capability Cost Ops Complexity Best For
Jaeger Cassandra, ES Rich High High Complex queries, service maps
Tempo S3, GCS, Azure trace_id only Low Low High volume, cost-sensitive
Zipkin MySQL, ES Basic Medium Low Small deployments, quick start
Datadog/New Relic Managed SaaS Rich High None No ops burden, integrated platform
Key Insight
Tempo's tradeoff is brilliant for high volume: Store everything cheaply in S3, only query by trace_id. Discovery happens via logs/metrics ("slow checkout requests" → get trace_ids from logs → fetch traces from Tempo). This 10x cost reduction enables 100% sampling for errors/slow requests.

Cloud Provider Options

  • AWS X-Ray: Managed tracing, integrates with Lambda/ECS/EKS, basic UI
  • Google Cloud Trace: Managed, integrates with GKE/Cloud Run, good UI
  • Azure Application Insights: Full observability platform, rich correlation

These are convenient but more expensive than self-hosted and lock you into the vendor.

7. Trace Analysis

Finding Bottlenecks

The primary use of traces is identifying performance bottlenecks. Key techniques:

1. Waterfall Inspection

Look for long spans in the critical path:

Trace ID: abc123 | Total: 3.2s

GET /checkout                           |====================================| 3.2s
  auth.verify                            |=|                                     0.1s
  order.create                              |==============================| 2.8s
    inventory.check                              |===|                         0.3s
    payment.charge                               |====================|      2.0s ← BOTTLENECK
      stripe.api.call                                |=================|     1.8s ← ROOT CAUSE
    email.send                                                     |=|       0.2s

Analysis: 56% of latency is Stripe API. Optimize: cache payment tokens, parallel processing.
                    

2. Span Duration Aggregation

Query for slow operations across all traces:

SELECT operation, AVG(duration), P50(duration), P99(duration), COUNT(*)
FROM spans
WHERE timestamp > NOW() - 1h
GROUP BY operation
ORDER BY P99(duration) DESC;

Results:
stripe.api.call       avg=1.8s  p99=5.2s   count=12,450  ← Investigate
db.query.orders       avg=0.05s p99=0.8s   count=45,230
redis.get             avg=0.003s p99=0.01s count=230,450

3. Error Correlation

Find traces with errors and analyze common patterns:

// Find all traces with errors
WHERE status = ERROR

// Group by error type
GROUP BY error_message

Results:
"Connection timeout to payment-service"  count=1,234  ← 500s at checkout
"Invalid inventory ID"                   count=45     ← Data issue

Critical Path Analysis

The critical path is the longest execution path in the trace. Optimizing non-critical spans doesn't improve latency:

                     ┌─ inventory.check (0.3s, parallel)
order.create (2.8s) ─┼─ payment.charge (2.0s, critical path)
                     └─ email.send (0.2s, parallel)

Critical path: order.create → payment.charge = 2.0s
Optimizing inventory.check saves 0s (it's parallel with payment.charge).
Optimizing payment.charge saves up to 2.0s (it's blocking).
                    

Service Dependency Maps

Traces reveal service dependencies automatically:

Auto-Generated Service Map
graph LR A[API Gateway] -->|12K req/s| B[Frontend Service] B -->|12K req/s| C[Auth Service] B -->|8K req/s| D[Order Service] D -->|8K req/s| E[Inventory Service] D -->|8K req/s| F[Payment Service] F -->|8K req/s| G[Stripe API] D -->|8K req/s| H[Email Service] D -->|8K req/s| I[PostgreSQL] style F fill:#f87171 style G fill:#fb923c

Red/orange indicate high latency or error rate. Payment Service and Stripe API need investigation.

Error Correlation

Traces make it easy to correlate errors across services:

  • Root cause: Find the first error in the trace (earliest span with error flag)
  • Cascading failures: See how upstream errors cause downstream timeouts/errors
  • Partial failures: Identify which services succeeded vs failed in a multi-step flow
Interview Question
Q: How do you use traces to debug a production incident?

A: (1) Start with error rate spike in monitoring, (2) Query traces with errors in that time window, (3) Inspect waterfall to find root cause span (first error), (4) Check span attributes for context (HTTP status, error message, DB query), (5) Aggregate similar errors to find commonality (same endpoint? same user segment?), (6) Use service map to see if error is localized or cascading, (7) Compare error traces to successful traces (same flow, different outcome) to isolate the difference.

Latency Regression Detection

Compare traces before/after a deployment to detect regressions:

// Before deployment
SELECT P50(duration), P99(duration)
FROM traces
WHERE service = 'order-service'
  AND timestamp BETWEEN deploy_time - 1h AND deploy_time;

// After deployment
SELECT P50(duration), P99(duration)
FROM traces
WHERE service = 'order-service'
  AND timestamp BETWEEN deploy_time AND deploy_time + 1h;

Result:
Before: p50=0.5s, p99=1.2s
After:  p50=0.9s, p99=2.8s  ← Regression! Rollback or investigate.

Trace Comparison

Compare two traces side-by-side to find differences:

Span Fast Trace (0.8s) Slow Trace (3.2s) Delta
auth.verify 0.1s 0.1s +0s
inventory.check 0.05s 0.3s +0.25s
payment.charge 0.2s 2.0s +1.8s
email.send 0.15s 0.2s +0.05s

Insight: payment.charge is 10x slower. Check span attributes for details (retries? timeout? external API latency?).

8. Performance Impact

Overhead of Tracing

Distributed tracing adds overhead to every request:

  • CPU: Span creation, serialization (Protobuf/JSON), context propagation
  • Memory: Buffering spans before export
  • Network: Sending spans to collector
  • Latency: If blocking on export (rare)

Typical overhead: 1-5% of application CPU and latency. At high traffic (10K+ req/sec), this can be significant.

Reducing Cost

1. Sampling

Most effective: Sample 1-5% instead of 100%.

  • Cost reduction: 20-100x
  • Tradeoff: Lower resolution, rare events missed
  • Mitigation: Tail-based sampling keeps errors/slow traces

2. Async Export

Export spans in background threads, don't block requests:

// Bad: Blocking export adds latency
span.end();
exporter.export(span); // Blocks request for 10-50ms

// Good: Async export
span.end();
spanBuffer.add(span); // Non-blocking
// Background thread flushes buffer periodically

OpenTelemetry SDK does this by default.

3. Batching

Send spans in batches (100-1000 at a time) instead of individually:

  • Network efficiency: 1 request instead of 1000
  • Backend efficiency: Bulk writes faster than individual writes
  • Tradeoff: Slight export delay (1-5 seconds)
const exporter = new OTLPTraceExporter({
  url: 'http://collector:4318/v1/traces',
  maxQueueSize: 2048,
  maxExportBatchSize: 512,
  scheduledDelayMillis: 5000 // Flush every 5s
});

4. Reduce Span Cardinality

Avoid high-cardinality attributes that explode storage:

// Bad: Unbounded cardinality
span.setAttribute('user.email', 'user@example.com'); // Millions of unique values

// Good: Low cardinality
span.setAttribute('user.tier', 'premium'); // Few unique values
// Store email in logs, not spans

5. Compression

Compress spans before sending (gzip, zstd):

const exporter = new OTLPTraceExporter({
  compression: 'gzip' // 70-90% size reduction
});

Cost Analysis

Example: 10,000 req/sec, 20 spans/req, 100% sampling:

Cost Calculation
Spans/sec: 10,000 req/sec × 20 spans/req = 200,000 spans/sec
Spans/day: 200,000 × 86,400 = 17.3 billion spans/day
Storage (2 KB/span): 17.3B × 2 KB = 34.6 TB/day
Storage cost (30 days, $0.023/GB/month in S3): ~$24,000/month

With 5% sampling: $1,200/month (20x reduction)
With 1% sampling: $240/month (100x reduction)
Key Insight
For high-traffic systems, use Tempo (cheap S3 storage) + tail-based sampling (keep all errors/slow traces) + 1% head-based sampling (for normal traces). This gives you full visibility into problems while keeping costs low. Example: Netflix, Uber, Shopify use this pattern.

Performance Testing

Before deploying tracing to production:

  1. Load test: Measure latency/throughput with tracing on vs off
  2. Resource usage: Monitor CPU, memory, network with tracing enabled
  3. Backend capacity: Ensure collector/backend can handle peak load
  4. Export failure: Verify app continues working if collector is down
// Load test results
Without tracing: p99 latency = 450ms, CPU = 60%
With tracing:    p99 latency = 475ms, CPU = 62%

Overhead: +25ms latency (+5.5%), +2% CPU
Acceptable for most systems.

9. Service Maps

Auto-Generated Dependency Graphs

One of tracing's killer features: automatically generate service dependency graphs from traces. No manual documentation needed.

How It Works

The tracing backend analyzes traces and builds a graph:

  1. Each unique service.name becomes a node
  2. Parent-child span relationships become edges
  3. Aggregate metrics (request rate, latency, error rate) annotate edges
Service Dependency Graph
graph TB A[API Gateway
12K req/s
0.5% errors] B[Frontend
12K req/s
0.2% errors] C[Auth
12K req/s
0.1% errors] D[Order Service
8K req/s
1.2% errors] E[Inventory
8K req/s
0.3% errors] F[Payment
8K req/s
2.5% errors] G[Email
8K req/s
0.1% errors] H[(PostgreSQL)] I[(Redis)] J[Stripe API] A --> B B --> C B --> D D --> E D --> F D --> G D --> H E --> I F --> J style F fill:#f87171,color:#fff style J fill:#fb923c

Red/orange nodes indicate high error rate. Payment Service (2.5% errors) likely caused by Stripe API issues.

Traffic Flow Visualization

Service maps show request flow and volume:

  • Edge width: Proportional to request rate
  • Edge color: Green (healthy), yellow (slow), red (errors)
  • Node size: Request volume or latency
  • Latency heatmap: Which services are slow?

Use Cases

1. Onboarding New Engineers

Service maps are live architecture diagrams. New engineers can see:

  • What services exist
  • How they interact
  • Which services are critical (high traffic)
  • Which are flaky (high error rate)

2. Impact Analysis

Before modifying a service, check its dependencies:

  • Which services call this one? (Upstream dependencies)
  • Which services does it call? (Downstream dependencies)
  • What's the blast radius if it goes down?

3. Incident Response

During incidents, service maps show:

  • Blast radius: Which services are affected?
  • Root cause: Where did errors start?
  • Cascading failures: How did errors propagate?
Incident: Payment Service down (100% errors)

Service Map Analysis:
- Upstream: Order Service (8K req/s → 100% timeout errors)
- Downstream: Stripe API (0 req/s, Payment Service not reaching it)
- Blast radius: Order Service, Checkout flow, 8K users/sec affected

Root cause: Payment Service pod crash-looping (OOM).
Fix: Scale up memory, redeploy.
                    

4. Architecture Smells

Service maps reveal architecture issues:

  • Chatty services: Service A calls Service B 100 times per request (N+1 queries)
  • Circular dependencies: A → B → C → A (deadlock risk)
  • God services: One service called by all others (SPOF, bottleneck)
  • Orphaned services: No incoming traffic (unused, safe to delete?)
Interview Question
Q: How would you detect circular dependencies in a microservices architecture?

A: Use the auto-generated service dependency graph from distributed tracing. Run cycle detection algorithm (DFS with a visited stack). Any back-edge indicates a cycle. Example: If Order Service calls Payment Service, which calls Fraud Service, which calls Order Service to check history, you have a cycle. This creates deadlock risk and tight coupling. Fix by introducing async messaging or a shared data service.

Real-Time vs Historical

  • Real-time: Service map updates continuously, shows current state. Use for monitoring, incident response.
  • Historical: Generate service map for a specific time window. Use for postmortems, capacity planning, comparing before/after changes.

Multi-Cluster Service Maps

In multi-region/multi-cluster deployments, service maps can show cross-region traffic:

Multi-Region Service Map
graph LR A[User us-east-1] --> B[API Gateway us-east-1] B --> C[Order Service us-east-1] C --> D[Payment Service us-west-2] D --> E[Stripe API] F[User eu-west-1] --> G[API Gateway eu-west-1] G --> H[Order Service eu-west-1] H --> D style D fill:#fbbf24

Cross-region calls (Order Service → Payment Service) add 50-100ms latency. Consider deploying Payment Service in each region.

10. Best Practices

Span Naming Conventions

Use consistent, hierarchical span names for aggregation and filtering:

Category Convention Example
HTTP http.{method} {route} http.GET /api/orders/:id
Database db.{operation} {table} db.query orders
RPC {service}/{method} PaymentService/Charge
Message Queue queue.{operation} {topic} queue.publish order-created
Business Logic {domain}.{operation} order.create
Warning
Don't include IDs or high-cardinality data in span names. Use attributes instead.

Bad: GET /api/orders/12345 (millions of unique span names)
Good: GET /api/orders/:id + attribute order.id=12345

Attribute Standards

Use OpenTelemetry semantic conventions for common attributes:

HTTP Attributes

{
  "http.method": "POST",
  "http.route": "/api/orders",
  "http.status_code": 201,
  "http.url": "https://api.example.com/api/orders",
  "http.user_agent": "Mozilla/5.0...",
  "http.request_content_length": 1024,
  "http.response_content_length": 256
}

Database Attributes

{
  "db.system": "postgresql",
  "db.name": "production",
  "db.statement": "SELECT * FROM orders WHERE id = ?",
  "db.operation": "SELECT",
  "db.connection_string": "postgresql://localhost:5432"
}

Custom Business Attributes

{
  "user.id": "usr_abc123",
  "user.tier": "premium",
  "tenant.id": "tenant_xyz",
  "order.id": "ord_456",
  "order.total": 49.99,
  "payment.gateway": "stripe",
  "feature.flag.checkout_v2": true
}

Trace Context in Async Operations

Propagating context through queues and background jobs:

Message Queue (Kafka)

// Producer: Inject context
const span = tracer.startSpan('kafka.produce');
const headers = {};
propagation.inject(context.active(), headers);

await producer.send({
  topic: 'order-created',
  messages: [{
    key: orderId,
    value: orderData,
    headers: headers // Propagate trace context
  }]
});
span.end();

// Consumer: Extract context
for await (const message of consumer) {
  const parentContext = propagation.extract(context.active(), message.headers);
  const span = tracer.startSpan('kafka.consume', { parent: parentContext });

  await processOrder(message.value);
  span.end();
}

Background Jobs (Redis Queue)

// Enqueue job with trace context
const span = tracer.startSpan('job.enqueue');
const traceContext = {};
propagation.inject(context.active(), traceContext);

await queue.add('process-order', {
  orderId: '12345',
  _trace: traceContext // Embed trace context in job data
});
span.end();

// Worker: Extract and continue trace
queue.process('process-order', async (job) => {
  const parentContext = propagation.extract(context.active(), job.data._trace);
  const span = tracer.startSpan('job.process', { parent: parentContext });

  await processOrder(job.data.orderId);
  span.end();
});

Baggage for Cross-Cutting Data

Use baggage to propagate context that all services need:

// API Gateway: Set baggage
const span = tracer.startSpan('http.request');
baggage.setItem('user.id', userId);
baggage.setItem('tenant.id', tenantId);
baggage.setItem('request.id', requestId);

// All downstream services automatically get baggage
function processOrder() {
  const userId = baggage.getItem('user.id'); // Available without passing parameters
  const span = tracer.startSpan('order.create', {
    attributes: {
      'user.id': userId // Add to span for searchability
    }
  });
}

Use cases:

  • User/tenant ID (for multi-tenancy, filtering)
  • Request ID (correlation with logs)
  • Feature flags (A/B testing, canary)
  • Security context (auth scopes, roles)

Span Lifecycle Management

Always close spans, even on errors:

// Bad: Span leaks on error
const span = tracer.startSpan('operation');
await doWork(); // Throws error
span.end(); // Never called

// Good: Use try-finally
const span = tracer.startSpan('operation');
try {
  await doWork();
  span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
  span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
  span.recordException(error);
  throw error;
} finally {
  span.end(); // Always called
}

Testing Strategies

1. Unit Tests with Mock Tracer

const { InMemorySpanExporter, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');

const exporter = new InMemorySpanExporter();
const provider = new TracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));

// Run code under test
await processOrder('12345');

// Assert spans
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(3);
expect(spans[0].name).toBe('order.create');
expect(spans[0].attributes['order.id']).toBe('12345');

2. Integration Tests: Verify Context Propagation

// Test that trace_id propagates across services
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';

// Service A
const response = await fetch('http://service-a', {
  headers: { 'traceparent': `00-${traceId}-0000000000000001-01` }
});

// Service B (called by Service A)
const serviceBSpans = await getSpans('service-b', traceId);
expect(serviceBSpans[0].traceId).toBe(traceId); // Same trace!

Production Checklist

Pre-Production Checklist
  • Auto-instrumentation enabled for all services
  • W3C TraceContext propagation configured
  • Sampling strategy defined (1-5% head-based + tail-based for errors)
  • Async export enabled (non-blocking)
  • Batching configured (500-1000 spans/batch)
  • Collector deployed and scaled (HA, auto-scaling)
  • Storage backend configured (Jaeger/Tempo/Zipkin)
  • Retention policy set (7-30 days)
  • Service map dashboard created
  • Alerts configured (trace export failures, missing spans)
  • Load tests completed (measure overhead)
  • Context propagation tested across all boundaries (HTTP, queues, gRPC)
Interview Question
Q: Design a distributed tracing system for a high-traffic e-commerce platform (100K req/sec, 50+ microservices).

A: (1) Instrumentation: OpenTelemetry auto-instrumentation for all services (Java, Node.js, Go), (2) Propagation: W3C TraceContext via HTTP/gRPC headers, Kafka message headers, (3) Sampling: 1% head-based sampling + tail-based sampling (keep all errors, latency > p99), (4) Collection: OpenTelemetry Collector fleet (10-20 instances, auto-scaled), (5) Storage: Grafana Tempo with S3 (cheap, scalable to trillions of spans), (6) Discovery: Correlate logs → trace IDs → fetch traces from Tempo, (7) Service maps: Auto-generated from traces, updated real-time, (8) Cost: ~$2-5K/month for storage (vs $50K+ with Jaeger/Elasticsearch), (9) Retention: 30 days for errors/slow, 7 days for normal traces.