Home › Systems building blocks › Observability & alerting
algorithm medium

Distributed tracing

Problem Engineers whose single request crosses several services need to see where that one request spent its time or failed, but each service only holds its own logs and metrics.

Give each request a 128-bit id, record timed spans linked by parent ids, pass the ids in a header across every hop, and join the spans back into a tree afterwards.

O(1) per span (one id, one clock read, one bounded-queue push); 55-byte header per hop; assembly O(spans)
tracingspanscontext-propagationw3c-trace-contextopentelemetrylogs
Overview

Logs and metrics are per service, but one user action touches several of them. Tracing gives the action a trace id at the edge, records each unit of work as a span (id, parent id, start, end, attributes) and passes the ids to the next service in a request header. A collector groups spans by trace id and rebuilds the call tree; the same ids in every log line join logs to it.

Key facts

Use when

  • Microservice backends: find the slow or failing hop between gateway, services and database.
  • Game servers: follow one player action from connection gateway through match service to the database.
  • Payment flows, mobile apps (the app starts the trace), data pipelines (a span per stage), LLM agent chains (Agent tracing).

Avoid when

  • One process: a profiler or structured logs are cheaper.
  • Every event must be kept (audit): traces are sampled and may drop; use a log.

Deep dive

How it works

Parts:

  • Context — (trace_id u128, span_id u64, flags), immutable, copied across calls.
  • Span — name, ids, parent id, start/end, attributes, events, status, links.
  • Context holder — thread-local stack (task-local in async runtimes); top = current span.
  • Propagator — reads/writes traceparent, tracestate, baggage on a carrier (HTTP header, gRPC metadata, message header).
  • Export queue — bounded buffer plus background batcher; drops and counts when full.
  • Collector — groups spans by trace id, waits for late ones, may sample, stores.
  1. Start. Parse an incoming traceparent: valid → same trace id, parent id = caller's span id; absent or invalid → new trace (random id, no parent). Without a remote parent, the parent is the top of the stack.
  2. Sample. The root decides once (head) and sets the sampled flag; children obey it. Unsampled spans still propagate ids but record nothing.
  3. Run. Push the context; add attributes, events, status. Log lines read the top of the stack.
  4. Propagate. Before an outgoing call write the current span id as parent-id; the callee's span becomes its child.
  5. End. A guard pops the stack, sets the end (monotonic duration on a wall-clock start) and tries the queue: full means drop and count.
  6. Export and assemble. A background thread sends a batch when it is full or a timer fires; the collector groups by trace id and judges a trace complete after the root arrives plus a wait window.
client ─traceparent 00-T-A-01─► gateway(A) ─00-T-B-01─► match(B) ─► db(C)
spans:   A(parent -)   B(parent A)   C(parent B)       all carry trace id T
process: span ─► bounded queue ─(batch; drop if full)─► collector: group by T ─► tree A→B→C
logs:    {"trace_id":"T","span_id":"B","msg":...}      joins the log line to span B

Wire format (Trace Context Level 1, W3C Recommendation 23 Nov 2021): version "-" trace-id "-" parent-id "-" trace-flags, with trace-id = 32 HEXDIGLC, parent-id = 16 HEXDIGLC, trace-flags = 2 HEXDIGLC (lowercase hex). Example: traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. Version ff and all-zero ids are invalid; an invalid header means a new trace. Flag bit 0 is sampled, a recommendation; other bits are sent as 0 and masked when read. tracestate holds vendor key=value pairs (at most 32). baggage (W3C Candidate Recommendation, 30 May 2024) carries application pairs to every service (baggage: userId=alice; limits 64 members, 8192 bytes) and can leak data to third parties.

In-process and queues. Thread-local context must be copied into spawned threads, callbacks and async tasks (Dapper did it in its callback library). Over a queue, put traceparent in the message headers; the consumer starts a span from it. A consumer of a batch has many parents: give it its own span with links to each (same or other trace), not one parent.

Head vs tail sampling. Head: decided at the root, cheap, blind to the outcome. Tail: the collector buffers whole traces and keeps errors and slow ones, costing memory and a wait window (contrib tail sampler defaults: decision_wait 30 s, 50 000 traces). See Telemetry sampling and Hash-based sampling.

Clock skew. Each host stamps spans with its own clock, so a child can appear to start before its parent. Dapper bounds a server span because an RPC request is sent before it is received and the reply after it. Viewers clamp or flag such spans; do not trust cross-host order beyond the clock sync (Hybrid logical clocks).

Logs. Write trace_id and span_id into every structured log line (OpenTelemetry log records have TraceId, SpanId, TraceFlags; a SpanId implies a TraceId). Deadlines ride the same hop (Deadline propagation).

Code

use std::{cell::{Cell, RefCell}, collections::VecDeque, sync::{atomic::{AtomicU64, Ordering::Relaxed}, Mutex}};
use std::time::{Instant, SystemTime, UNIX_EPOCH};

#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Ctx { pub trace: u128, pub span: u64, pub sampled: bool }

pub struct Span {
    pub ctx: Ctx, pub parent: u64, pub name: &'static str,
    pub start_ns: u64, pub end_ns: u64, pub attrs: Vec<(&'static str, String)>,
}

static SEED: AtomicU64 = AtomicU64::new(1);
thread_local! {
    static RNG: Cell<u64> = Cell::new(SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64
        ^ SEED.fetch_add(0x9E3779B97F4A7C15, Relaxed)); // time + a different offset per thread
    static STACK: RefCell<Vec<Ctx>> = RefCell::new(Vec::new());
}
fn next_u64() -> u64 { // splitmix64; not cryptographic, only needs to be unique and well spread
    RNG.with(|r| {
        let z = r.get().wrapping_add(0x9E3779B97F4A7C15); r.set(z);
        let z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
        let z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
        z ^ (z >> 31)
    })
}
fn nonzero_u64() -> u64 { loop { let v = next_u64(); if v != 0 { return v; } } }

// Bounded export queue: try_lock + length check, so a slow exporter never blocks a request.
static QUEUE: Mutex<VecDeque<Span>> = Mutex::new(VecDeque::new());
pub static DROPPED: AtomicU64 = AtomicU64::new(0);
pub static CAP: AtomicU64 = AtomicU64::new(2048);
fn export(s: Span) {
    match QUEUE.try_lock() {
        Ok(mut q) if (q.len() as u64) < CAP.load(Relaxed) => q.push_back(s),
        _ => { DROPPED.fetch_add(1, Relaxed); }
    }
}
pub fn drain() -> Vec<Span> { QUEUE.lock().unwrap().drain(..).collect() }

/// RAII guard: the span ends (and is exported) when it goes out of scope.
pub struct Guard { span: Option<Span>, t0: Instant }
pub fn start(name: &'static str, remote: Option<Ctx>) -> Guard {
    let parent = remote.or_else(|| STACK.with(|s| s.borrow().last().copied()));
    let ctx = Ctx {
        trace: parent.map_or_else(|| (next_u64() as u128) << 64 | nonzero_u64() as u128, |p| p.trace),
        span: nonzero_u64(),
        sampled: parent.map_or(true, |p| p.sampled), // root: head decision (always-on here)
    };
    STACK.with(|s| s.borrow_mut().push(ctx));
    let start_ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
    Guard { span: Some(Span { ctx, parent: parent.map_or(0, |p| p.span), name, start_ns, end_ns: 0, attrs: vec![] }), t0: Instant::now() }
}
impl Guard {
    pub fn attr(&mut self, k: &'static str, v: impl ToString) { if self.span.as_ref().unwrap().ctx.sampled { self.span.as_mut().unwrap().attrs.push((k, v.to_string())); } }
    pub fn ctx(&self) -> Ctx { self.span.as_ref().unwrap().ctx }
}
impl Drop for Guard {
    fn drop(&mut self) {
        STACK.with(|s| { s.borrow_mut().pop(); });
        let mut sp = self.span.take().unwrap();
        sp.end_ns = sp.start_ns + self.t0.elapsed().as_nanos() as u64; // monotonic duration, wall-clock start
        if sp.ctx.sampled { export(sp); }
    }
}

// W3C Trace Context, version 00: "00-<32 hex>-<16 hex>-<2 hex>", lowercase, 55 bytes.
pub fn format_traceparent(c: &Ctx) -> String { format!("00-{:032x}-{:016x}-{:02x}", c.trace, c.span, c.sampled as u8) }
fn lower_hex(s: &str, n: usize) -> bool { s.len() == n && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) }
pub fn parse_traceparent(h: &str) -> Option<Ctx> {
    let p: Vec<&str> = h.split('-').collect();
    if h.len() != 55 || p.len() != 4 || p[0] != "00" || !lower_hex(p[1], 32) || !lower_hex(p[2], 16) || !lower_hex(p[3], 2) { return None; }
    let (trace, span) = (u128::from_str_radix(p[1], 16).ok()?, u64::from_str_radix(p[2], 16).ok()?);
    if trace == 0 || span == 0 { return None; } // all-zero ids are invalid
    Some(Ctx { trace, span, sampled: u8::from_str_radix(p[3], 16).ok()? & 1 == 1 }) // mask: only bit 0 is defined
}

/// Structured log line carrying the current trace and span id.
pub fn log_line(level: &str, msg: &str) -> String {
    let (t, s) = STACK.with(|st| st.borrow().last().map_or((0, 0), |c| (c.trace, c.span)));
    format!("{{\"level\":\"{level}\",\"trace_id\":\"{t:032x}\",\"span_id\":\"{s:016x}\",\"msg\":\"{}\"}}", msg.replace('\\', "\\\\").replace('"', "\\\""))
}

fn main() {
    CAP.store(4, Relaxed);
    let hdr;
    {   // gateway: root span, "send" the header to the match service
        let mut g = start("gateway /move", None);
        g.attr("player", 42);
        hdr = format_traceparent(&g.ctx());
        let remote = parse_traceparent(&hdr).expect("valid");   // match service: extract
        assert_eq!(remote, g.ctx());
        let m = start("match.apply", Some(remote));              // child of the remote span
        assert_eq!((m.ctx().trace, m.span.as_ref().unwrap().parent), (g.ctx().trace, g.ctx().span));
        let _db = start("db.insert", None);                      // implicit parent: top of the thread-local stack
        let line = log_line("info", "stored \"e2e4\"");
        assert!(line.contains(&format!("{:032x}", g.ctx().trace)) && line.contains(&format!("{:016x}", _db.ctx().span)));
        println!("{hdr}\n{line}");
    }
    let spans = drain();
    assert_eq!(spans.len(), 3);                                  // db, match, gateway end in that order
    assert_eq!(spans[0].parent, spans[1].ctx.span);
    assert!(spans.iter().all(|s| s.ctx.trace == spans[2].ctx.trace && s.end_ns >= s.start_ns));
    // spec-invalid headers
    let (t, s) = ("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7");
    for bad in [String::new(), format!("00-{}-{s}-01", "0".repeat(32)), format!("00-{t}-{}-01", "0".repeat(16)), format!("ff-{t}-{s}-01"),
        format!("00-{}-{s}-01", t.to_uppercase()), format!("00-{t}-{s}-1"), format!("00-+{}-{s}-01", &t[1..])] {
        assert!(parse_traceparent(&bad).is_none(), "{bad}");
    }
    assert!(parse_traceparent(&format!("00-{t}-{s}-00")).is_some_and(|c| !c.sampled));
    // overflow: cap 4, 10 spans -> 4 kept, 6 dropped and counted
    DROPPED.store(0, Relaxed);
    for _ in 0..10 { let _s = start("x", None); }
    assert_eq!((drain().len(), DROPPED.load(Relaxed)), (4, 6));
    // throughput: root span + 1 attribute, drained in batches of 1024
    CAP.store(1024, Relaxed);
    let n = 2_000_000; let t = Instant::now();
    for i in 0..n { { let mut s = start("op", None); s.attr("k", i); } if i % 1024 == 1023 { drain(); } }
    println!("{:.2} M spans/s", n as f64 / t.elapsed().as_secs_f64() / 1e6);
}

Asserts pass. 9.6-10.4 M root spans/s (one attribute, drained per 1024), measured on this Mac (Apple M4 Pro, --release, one thread, four runs).

Complexity and constants

Per span: two random draws, two clock reads, a String per attribute, one queue push. Header: 55 bytes per hop. Cost drivers: span allocation, attribute strings, export bytes and attribute cardinality (user ids as values blow up backend indexes). The collector holds the spans in flight for the wait window.

Efficiency

  • Cost: a root span with one attribute is about 100 ns, no syscall on the request path (measured on this Mac, Apple M4 Pro). Dapper (2.2 GHz x86): 204 ns root span, 176 ns child; an annotation 9 ns unsampled, 40 ns sampled. Bound: allocation and export bytes.
  • Levers:
    • Head sampling — latency +16.3 % at 1/1, -0.20 % at 1/1024 (Dapper) — high-volume services.
    • Cheap unsampled path (no allocation) — 9 ns vs 40 ns (Dapper) — always.
    • Batch export — OpenTelemetry batch processor defaults: queue 2048, batch 512, delay 5000 ms, overflow dropped (SDK spec) — always.
    • Low-cardinality attributes, count and length limits — fewer bytes and index entries — user-supplied values.
    • Tail sampling at a collector — keep errors and slow traces — rare failures head sampling misses.
  • Naive → standard → state of the art: export each span synchronously · blocks on the network; bounded queue, batch exporter, head sampling · request path never blocks, loss counted; head sampling for volume plus collector tail sampling · rare bad traces kept for a small share of the bytes.

Implementation notes

  • Parse strictly: from_str_radix accepts + and upper case, which the spec forbids.
  • No secrets in attributes or baggage. At a public edge, restart the trace rather than trust a client's traceparent.
  • try_lock or lock-free queue so a stalled exporter cannot stall a handler; a per-thread SPSC ring is the fast form.

Pitfalls

  • Context lost at a thread pool, channel or spawn: the child becomes a new root.
  • Services sampling independently: traces with holes. Honour the incoming flag.
  • Span not ended on an error path: use a guard.
  • Ids or long strings as attribute values: large bills, slow queries.
  • Collector wait too short: late spans miss their trace.

Variants and alternatives

Zipkin B3 headersolder propagation format; W3C unless a peer needs B3.
Metrics and Burn-rate alertingsay that it is slow; traces say where.

In production

Google Dapper, Zipkin, Jaeger, Grafana Tempo (trace storage), the OpenTelemetry Collector. In Rust, tracing instruments code and tracing-opentelemetry bridges its spans to OpenTelemetry.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
opentelemetry-rustRustopen-telemetry/opentelemetry-rustcargo add opentelemetry opentelemetry_sdk————API, SDK, batch processor, W3C propagator; OTLP in opentelemetry-otlp
tracing-opentelemetryRusttokio-rs/tracing-opentelemetrycargo add tracing tracing-opentelemetry————bridges tracing (repo) spans to OpenTelemetry
OpenTelemetry CollectorGoopen-telemetry/opentelemetry-collectorrelease binary or container image————receive, batch, export; tail sampler in the contrib repo
JaegerGojaegertracing/jaegerrelease binary or container image————collector, storage, trace UI
ZipkinJavaopenzipkin/zipkindocker run -d -p 9411:9411 openzipkin/zipkin————collector, storage, UI; B3 propagation

Sources

  • Benjamin H. Sigelman et al., Dapper, a Large-Scale Distributed Systems Tracing Infrastructure, Google Technical Report 2010. paper
  • W3C, Trace Context, Recommendation, 23 Nov 2021. spec
  • W3C, Propagation format for distributed context: Baggage, Candidate Recommendation Snapshot, 30 May 2024. spec
  • OpenTelemetry, Tracing API, Tracing SDK and Logs data model specifications. API · SDK · logs
  • OpenTelemetry Collector contrib, tailsamplingprocessor. code

Builds on

Used by

Systems (9):

Alternatives and related