Distributed Tracing Across Microservices

By Oleksandr Andrushchenko — Published on
0 Likes
0 Dislikes
Distributed Tracing Across Microservices
Distributed Tracing Across Microservices

Distributed systems make latency and failures harder to explain because a single user request can cross several services, queues, databases, caches, and external providers. Each component may look healthy in isolation while the complete request is slow, repeatedly retried, or partially failing.

Distributed tracing solves this by connecting related operations into one causal request graph. A trace represents the complete operation, while spans represent individual units of work such as HTTP requests, database queries, message consumption, cache access, or external API calls.

In production, the challenge is not simply creating spans. A useful tracing system must propagate context across synchronous and asynchronous boundaries, choose meaningful span boundaries, control sampling and cardinality, protect sensitive data, and operate a trace pipeline that can handle failures without affecting application traffic.

Table of Contents

How Distributed Tracing Works

A trace models one logical operation from its entry point through the downstream work caused by that operation.

Consider a checkout request:


Client
  |
  v
API Gateway
  |
  v
Checkout Service
  |
  +------> Inventory Service
  |              |
  |              +------> PostgreSQL
  |
  +------> Payment Service
                 |
                 +------> Payment Provider

Metrics can reveal that checkout latency increased. Logs can show payment timeouts. A trace connects the full path and shows where time was actually spent.

Traces and Spans

A trace identifies the complete distributed operation. A span represents one unit of work inside that operation.


Trace: 47bd...

[ POST /checkout                    1.84 sec ]
   |
   +-- [ checkout-service           1.81 sec ]
          |
          +-- [ inventory-service     61 ms ]
          |      |
          |      +-- [ SELECT stock   17 ms ]
          |
          +-- [ payment-service      1.64 sec ]
                 |
                 +-- [ provider API  1.51 sec ]

A span commonly contains:

{
  "trace_id": "47bd24bf416f44eb967acc738016ca21",
  "span_id": "832aca7c12dc1901",
  "parent_span_id": "e84ac819178f9211",
  "service": "payment-service",
  "operation": "POST provider-payment",
  "duration_ms": 1514,
  "status": "ok",
  "region": "us-east",
  "version": "v84"
}

The trace ID stays stable across the complete operation. Each span receives its own span ID and links back to its parent.

This relationship matters because distributed operations are rarely a flat list of timings.


Checkout
 |
 +-- Inventory
 |
 +-- Payment
      |
      +-- Token Service
      |
      +-- Provider API

The hierarchy makes causality explicit.

Understanding the Critical Path

One important tracing concept is the critical path: the sequence of operations determining total end-to-end latency.

Suppose three services execute concurrently:


Checkout                      900 ms
 |
 +-- Inventory                120 ms
 |
 +-- Pricing                   80 ms
 |
 +-- Payment                  760 ms

The child durations add up to 960 ms, but the request still completes in 900 ms because operations overlap.

The payment path dominates end-to-end latency.

This distinction prevents a common debugging mistake: adding span durations together as if every operation executed sequentially.

Tracing is especially useful for discovering:

  • slow downstream dependencies;
  • unexpected sequential calls;
  • large service fan-out;
  • retries hidden inside request latency;
  • queue waiting time;
  • connection pool waits;
  • cross-region calls;
  • slow external providers.

Context Propagation Across Services

Tracing works only when services preserve the active trace context as requests move between processes.

Each downstream service extracts the incoming trace information, creates a child span, and forwards updated context when it calls another component.

HTTP and RPC Propagation

A synchronous request flow looks like:


Client
  |
  | trace=A
  v
API Gateway
  |
  | trace=A, parent=span-1
  v
Service A
  |
  | trace=A, parent=span-2
  v
Service B
  |
  | trace=A, parent=span-3
  v
Service C

The trace ID stays unchanged. Each service creates its own span and passes that span as the parent of the next operation.

Context propagation should normally be handled by tracing instrumentation rather than manually assembling headers throughout application code.

Advantages:

  • creates one end-to-end request graph;
  • works across independently deployed services;
  • allows traces and structured logs to share the same trace ID;
  • makes service boundaries visible without requiring centralized application code.

Disadvantages:

  • every supported protocol must preserve context;
  • custom clients may require additional instrumentation;
  • one broken propagation boundary fragments the trace;
  • cross-company boundaries may intentionally stop propagation.

A fragmented trace often looks like:


Trace A

API Gateway
   |
Order Service


Trace B

Payment Service
   |
Provider API

If Payment Service should be part of the original request, this shape usually indicates missing propagation.

Queues and Background Workers

Asynchronous workflows require special attention because the downstream operation may happen seconds, minutes, or hours after the original request.


API Request
    |
    v
Booking Service
    |
    v
Queue
    |
    | 45 seconds
    v
Booking Worker
    |
    v
Carrier API

The message should carry tracing metadata in headers or another propagation carrier:

{
  "headers": {
    "traceparent": "..."
  },
  "payload": {
    "shipment_id": "SHP-91821"
  }
}

The worker extracts the context and starts a processing span associated with the originating workflow.

Queue tracing should distinguish several different latency components:


Producer
   |
   | publish = 8 ms
   v
Queue
   |
   | waiting = 45 sec
   v
Worker
   |
   | processing = 120 ms
   v
Carrier API

If only processing time is recorded, the worker appears healthy while the user waits almost a minute.

For queued systems, traces should help expose:

  • publish latency;
  • queue waiting time;
  • consumer processing latency;
  • retry attempts;
  • dead-letter transitions;
  • downstream dependency latency.

Asynchronous tracing is particularly valuable when combined with queue age metrics. See Metrics That Actually Matter.

Designing Useful Spans

Tracing every local function produces expensive, noisy traces. A span should normally represent work that matters architecturally, operationally, or from a latency perspective.

Good span boundaries include:

  • incoming HTTP requests;
  • outgoing service calls;
  • database operations;
  • cache operations when they materially affect latency;
  • message publication;
  • message consumption;
  • external provider calls;
  • important workflow steps;
  • expensive internal computations.

A noisy trace might look like:


POST /shipments
 |
 +-- parse_json
 |
 +-- validate_string
 |
 +-- normalize_address
 |
 +-- convert_boolean
 |
 +-- build_dict
 |
 +-- serialize_model
 |
 +-- save

A better production trace would be:


POST /shipments
 |
 +-- validate shipment
 |
 +-- INSERT shipment
 |
 +-- rating-service
 |
 +-- publish booking event

The second trace preserves architectural information while avoiding low-value implementation noise.

Attributes should follow the same principle.

Attribute Example Recommendation
service booking-service Use
version v84 Use
region us-east Use
provider carrier-a Use when bounded
shipment_id SHP-91821 Use selectively
authorization header Bearer ... Never
full request body Large payload Avoid
full SQL result Thousands of rows Avoid

Trace attributes are often indexed or searched. Large payloads and highly sensitive values therefore create both cost and security problems.

Sampling Strategies at Scale

Trace volume grows with both request throughput and the number of spans per request.

Suppose:


Requests/sec = 150,000
Average spans/request = 12

Full tracing produces:


150,000 × 12
= 1,800,000 spans/sec

If an average encoded span is roughly 1 KB:


≈ 1.8 GB/sec

≈ 155 TB/day

The real storage footprint depends on compression, indexing, attributes, replicas, and retention, but tracing every request indefinitely can clearly become expensive.

Sampling reduces trace volume.

Head vs Tail Sampling

Two common approaches are head and tail sampling.

Property Head Sampling Tail Sampling
Decision point Near trace start After enough spans arrive
Memory requirements Low Higher
Can identify failures first? No Yes
Can retain slow traces selectively? No Yes
Operational complexity Lower Higher
Best use Simple predictable sampling High-value production diagnostics

Head sampling might keep 5% of all traces:


100 traces
   |
   v
random sampling
   |
   +--> 5 retained
   |
   +--> 95 dropped

The problem is that an important failed request may be discarded before its failure is known.

Tail sampling waits long enough to inspect outcomes:


Trace Buffer
    |
    +-- success 80 ms ------> sample 1%
    |
    +-- success 5 sec ------> retain
    |
    +-- HTTP 500 -----------> retain
    |
    +-- provider timeout ---> retain

A practical production policy might be:


Errors                    100%
Very slow traces          100%
Rare workflows             50%
Normal requests             5%
Health checks             0.1%

Healthy traces should still be retained because debugging often requires comparing a failing request with a normal request.

Advantages of head sampling:

  • simple;
  • predictable ingestion volume;
  • minimal collector memory;
  • easy horizontal scaling.

Disadvantages of head sampling:

  • cannot know final trace outcome;
  • rare failures can be discarded;
  • high-value latency outliers may disappear.

Advantages of tail sampling:

  • retains failed traces selectively;
  • retains latency outliers;
  • provides better diagnostic value per stored trace.

Disadvantages of tail sampling:

  • requires buffering;
  • higher memory requirements;
  • more operationally complex;
  • collector affinity may be needed so spans from the same trace meet at the same sampling layer.

Production Tracing Architecture

Tracing should not synchronously depend on the storage backend. Applications should record spans and export them asynchronously through a collector layer.


                         Services
                 /         |          \
                v          v           v
              API       Service     Worker
                \          |          /
                 \         |         /
                  v        v        v
                 Local Collectors
                        |
                        v
                 Gateway Collectors
                        |
                +-------+-------+
                |               |
                v               v
            Processing       Sampling
                \               /
                 \             /
                  v           v
                   Trace Storage
                        |
                        v
                Query / Visualization

The collector layer can handle:

  • batching;
  • retry policies;
  • memory limiting;
  • attribute enrichment;
  • redaction;
  • sampling;
  • routing to multiple backends.

The critical reliability rule is:


Application availability
        >
trace completeness

If trace storage is unavailable, losing some spans is normally safer than allowing telemetry buffers to consume all application memory or block requests.

The tracing infrastructure should itself expose:


spans_received_total
spans_exported_total
spans_dropped_total

collector_queue_size
collector_queue_capacity

export_errors_total

sampling_rate

collector_memory
collector_cpu

These metrics reveal whether missing traces are caused by healthy traffic or a broken telemetry pipeline.

Failure Scenarios and Trace Analysis

Tracing is particularly valuable when production failures emerge from interactions between systems.

Slow external dependency.


POST /checkout                   3.2 sec
 |
 +-- inventory                    60 ms
 |
 +-- payment                     3.0 sec
      |
      +-- provider               2.9 sec

The latency source is clear.

Database connection saturation.


POST /shipments                 2.0 sec
 |
 +-- acquire DB connection      1.7 sec
 |
 +-- INSERT shipment             42 ms

The SQL statement is not slow. Connection acquisition is.

Retry amplification.


Payment Service
 |
 +-- attempt 1 ---- 3 sec timeout
 |
 +-- backoff ------- 1 sec
 |
 +-- attempt 2 ---- 3 sec timeout
 |
 +-- backoff ------- 2 sec
 |
 +-- attempt 3 ---- 3 sec timeout

The trace reveals that much of the total request latency comes from retry policy rather than one slow operation.

Queue backlog.


Publish booking
      |
      | 14 minutes waiting
      v
Booking Worker
      |
      | 85 ms processing
      v
Carrier API

The worker is fast. The queue is the actual bottleneck.

Broken propagation.


Trace A:
API --> Service A

Trace B:
Service B --> Database

If Service B belongs to the request, propagation is broken.

Collector outage. Span buffers grow, exporter failures increase, and eventually spans may be dropped according to bounded policies. Business traffic should continue.

Trace backend slowdown. Collector queues increase. Scaling collectors alone may not solve the problem if the storage backend is the bottleneck.

Deployment failure. A new version can produce longer spans or more downstream calls. Service version should therefore be included in resource metadata so trace populations can be compared.

Production Design Example

Consider a logistics platform that creates shipments, calculates rates, books freight through carriers, and updates shipment status asynchronously.


                              Client
                                |
                                v
                          API Gateway
                                |
                                v
                        Shipment Service
                         /             \
                        v               v
                 Rating Service    Booking Service
                     /   \              |
                    v     v             v
                 Cache Carrier      Booking Queue
                                       |
                                       v
                                 Booking Worker
                                  /           \
                                 v             v
                           PostgreSQL      Carrier API

The synchronous shipment request creates one trace.


POST /shipments                       620 ms
 |
 +-- validate shipment                 12 ms
 |
 +-- rating-service                   310 ms
 |     |
 |     +-- cache GET                    8 ms
 |     |
 |     +-- carrier rates              270 ms
 |
 +-- INSERT shipment                   31 ms
 |
 +-- publish booking event             16 ms

Request flow. The API gateway creates or continues trace context. Shipment Service propagates it to Rating Service and downstream carrier requests.

Write flow. Shipment Service persists the shipment and publishes the booking event with trace context attached.

Asynchronous flow. Booking Worker extracts the context and creates a processing span.


Original request
      |
      v
publish booking
      |
      v
message broker
      |
      | waiting 4 sec
      v
booking worker
      |
      +-- SELECT shipment
      |
      +-- carrier booking
      |
      +-- UPDATE status

Failure flow. Carrier A starts timing out:


Metrics:

carrier_error_rate = 28%
booking_queue_age = 7 min

          |
          v

Trace:

booking-worker
 |
 +-- carrier attempt 1 ---- timeout
 |
 +-- retry delay
 |
 +-- carrier attempt 2 ---- timeout
 |
 +-- retry delay
 |
 +-- carrier attempt 3 ---- timeout

The trace explains why worker throughput fell: each booking now occupies a worker for multiple timeout windows and retry delays.

Monitoring. Application metrics track carrier errors, booking latency, queue age, retries, and worker throughput. Trace-platform metrics track ingestion, collector memory, sampling, dropped spans, and export failures.

Scaling. Booking workers scale based on queue age and processing rate. Tracing collectors scale independently based on spans per second and buffered trace state.

Deployment. Every span includes service version. During canary deployment, traces from the candidate version can be compared with stable instances.

If version v85 starts making one additional database query per request:


v84:

booking-worker
 |
 +-- SELECT shipment
 |
 +-- carrier
 |
 +-- UPDATE shipment


v85:

booking-worker
 |
 +-- SELECT shipment
 |
 +-- SELECT account
 |
 +-- carrier
 |
 +-- UPDATE shipment

The extra dependency becomes immediately visible in traces even before it causes a major latency regression.

Ready-to-Use Example

A Python service can use OpenTelemetry instrumentation for FastAPI while exporting spans asynchronously through an OTLP collector.

from dataclasses import dataclass

from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
    OTLPSpanExporter,
)
from opentelemetry.instrumentation.fastapi import (
    FastAPIInstrumentor,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor


@dataclass(frozen=True)
class ServiceConfig:
    name: str
    version: str
    environment: str
    region: str


CONFIG = ServiceConfig(
    name="shipment-service",
    version="v85",
    environment="production",
    region="us-east",
)


resource = Resource.create({
    "service.name": CONFIG.name,
    "service.version": CONFIG.version,
    "deployment.environment.name": CONFIG.environment,
    "cloud.region": CONFIG.region,
})

provider = TracerProvider(
    resource=resource,
)

exporter = OTLPSpanExporter(
    endpoint="http://otel-collector:4317",
    insecure=True,
)

provider.add_span_processor(
    BatchSpanProcessor(exporter)
)

trace.set_tracer_provider(provider)

app = FastAPI()

FastAPIInstrumentor.instrument_app(app)

tracer = trace.get_tracer(__name__)

The batch span processor avoids synchronously exporting every span from request handlers.

Custom spans should wrap important operations:

from dataclasses import dataclass


@dataclass(frozen=True)
class CarrierBooking:
    shipment_id: str
    carrier: str


async def book_with_carrier(
    booking: CarrierBooking,
) -> None:
    with tracer.start_as_current_span(
        "carrier.booking"
    ) as span:
        span.set_attribute(
            "carrier.name",
            booking.carrier,
        )

        try:
            await carrier_client.book(booking)

        except TimeoutError as exc:
            span.record_exception(exc)
            span.set_attribute(
                "error.type",
                "carrier_timeout",
            )
            raise

The carrier name is useful if the set of carriers is bounded. Authentication tokens, complete payloads, and sensitive customer data should not be attached to the span.

Context can be injected into asynchronous messages:

from typing import Any

from opentelemetry.propagate import inject


def build_booking_message(
    payload: dict[str, Any],
) -> dict[str, Any]:
    headers: dict[str, str] = {}

    inject(headers)

    return {
        "headers": headers,
        "payload": payload,
    }

The consumer restores the context:

from typing import Any

from opentelemetry.propagate import extract


async def consume_booking(
    message: dict[str, Any],
) -> None:
    context = extract(
        message["headers"]
    )

    with tracer.start_as_current_span(
        "booking.process",
        context=context,
    ):
        await process_booking(
            message["payload"]
        )

A collector can apply tail sampling and memory protection:

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 2048

  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    policies:
      - name: errors
        type: status_code
        status_code:
          status_codes:
            - ERROR

      - name: slow-traces
        type: latency
        latency:
          threshold_ms: 2000

  batch:
    send_batch_size: 4096
    timeout: 2s

exporters:
  otlp:
    endpoint: trace-storage:4317

service:
  pipelines:
    traces:
      receivers:
        - otlp
      processors:
        - memory_limiter
        - tail_sampling
        - batch
      exporters:
        - otlp

Tail sampling requires temporary trace state, so collector memory and trace arrival patterns must be capacity-planned. The memory limiter protects the collector itself, but production configuration should also define what happens when limits are reached.

Common Mistakes

Mistake Production Impact Better Approach
Tracing every function Large noisy traces increase cost and hide important operations. Instrument architectural and latency-relevant boundaries.
Tracing only inbound HTTP Database, queue, provider, and downstream bottlenecks remain invisible. Instrument meaningful dependencies as well.
Missing context propagation One request becomes several unrelated traces. Standardize propagation across supported transports.
Dropping context at queue boundaries Background work becomes disconnected from the originating request. Inject and extract context through message metadata.
Ignoring queue waiting time Fast workers can look healthy while users wait minutes. Expose publish, wait, and processing latency separately.
Tracing 100% indefinitely Storage and ingestion grow directly with traffic. Use deliberate sampling policies.
Sampling errors like healthy traffic Rare failures can disappear. Retain errors at a higher sampling rate.
Keeping only failures Healthy comparison traces are unavailable. Retain representative normal traffic.
Recording full request bodies Trace size and sensitive-data exposure increase. Record only operationally useful attributes.
Recording credentials Secrets can spread into trace indexes and archives. Never attach authentication material.
Synchronous span export Tracing infrastructure can increase application latency. Batch and export asynchronously.
Unlimited collector buffers Trace backend outages can exhaust memory. Use bounded queues and memory limits.
Ignoring dropped spans Missing telemetry can look like healthy traffic. Monitor exporter failures and span drops.
Missing service version Deployment regressions become harder to isolate. Attach version and environment metadata.
Using traces instead of metrics Aggregate behavior becomes expensive and statistically incomplete. Use metrics for population-level behavior and traces for individual operations.

Production Checklist

  • Trace inbound requests: continue or create context at service entry points.
  • Trace outbound requests: instrument meaningful service dependencies.
  • Use standard propagation: avoid custom trace-header schemes where unnecessary.
  • Propagate through queues: preserve context across asynchronous workflows.
  • Instrument databases: distinguish connection waits from query execution.
  • Instrument external providers: isolate third-party latency and failures.
  • Instrument retries: make repeated attempts visible inside traces.
  • Track queue wait time: separate backlog delay from worker latency.
  • Choose meaningful spans: avoid tracing trivial local functions.
  • Include service name: make ownership clear.
  • Include service version: support deployment comparison.
  • Include environment: separate production from non-production traces.
  • Include region: expose localized failures.
  • Use bounded attributes: avoid unnecessary high-cardinality indexing.
  • Never attach secrets: exclude credentials and authentication headers.
  • Review sensitive data: keep customer payloads out of spans by default.
  • Use asynchronous exporters: isolate tracing from request latency.
  • Batch spans: reduce exporter overhead.
  • Bound collector memory: prevent telemetry pressure from destabilizing collectors.
  • Monitor collector queues: detect downstream trace-storage pressure.
  • Monitor dropped spans: visibility loss must itself be visible.
  • Monitor exporter errors: detect storage and networking failures.
  • Monitor trace ingestion: catch instrumentation explosions after deployment.
  • Choose sampling intentionally: balance cost with diagnostic value.
  • Retain failed traces: preserve important incidents.
  • Retain slow traces: preserve latency outliers.
  • Retain healthy traces: provide comparison baselines.
  • Capacity-plan tail sampling: account for buffered trace state.
  • Test propagation: verify complete traces across every protocol boundary.
  • Test tracing outages: ensure telemetry failure cannot break application traffic.

Conclusion

Distributed tracing provides a causal view of production requests that cannot be reconstructed efficiently from isolated service logs or aggregate metrics alone. It reveals where latency accumulates, how services depend on one another, how retries amplify failures, and where asynchronous workflows spend time waiting rather than processing.

The production trade-off is telemetry volume and operational complexity. Context propagation must work across all relevant boundaries, spans must remain meaningful, sampling must preserve high-value requests, and collectors must be able to absorb failures without affecting the application being observed.

Key Takeaway: Treat traces as causal maps of meaningful distributed work. Propagate context through HTTP, RPC, queues, and workers; instrument service and dependency boundaries rather than every function; preserve failed, slow, and representative healthy traces; correlate traces with logs and metrics; and operate the tracing pipeline with explicit sampling, capacity, security, and failure policies.

Comments (0)