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,baggageon 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.
- 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. - Sample. The root decides once (head) and sets the sampled flag; children obey it. Unsampled spans still propagate ids but record nothing.
- Run. Push the context; add attributes, events, status. Log lines read the top of the stack.
- Propagate. Before an outgoing call write the current span id as
parent-id; the callee's span becomes its child. - 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.
- 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_radixaccepts+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_lockor 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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| opentelemetry-rust | Rust | open-telemetry/opentelemetry-rust | cargo add opentelemetry opentelemetry_sdk | — | — | — | — | API, SDK, batch processor, W3C propagator; OTLP in opentelemetry-otlp |
| tracing-opentelemetry | Rust | tokio-rs/tracing-opentelemetry | cargo add tracing tracing-opentelemetry | — | — | — | — | bridges tracing (repo) spans to OpenTelemetry |
| OpenTelemetry Collector | Go | open-telemetry/opentelemetry-collector | release binary or container image | — | — | — | — | receive, batch, export; tail sampler in the contrib repo |
| Jaeger | Go | jaegertracing/jaeger | release binary or container image | — | — | — | — | collector, storage, trace UI |
| Zipkin | Java | openzipkin/zipkin | docker 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