Skip to main content

osproxy_core/
trace.rs

1//! W3C Trace Context propagation, the identifiers the proxy continues from an
2//! incoming request and forwards to every downstream call so the upstream's
3//! spans join the same distributed trace (`docs/05` §2, `OTel`).
4//!
5//! **Shape-only by construction.** A [`TraceContext`] holds only opaque trace and
6//! span ids, correlation identity, never tenant values, bodies, or secrets. The
7//! ids are derived from the request id (not from request *data*), so propagation
8//! cannot become a value-leak channel.
9
10use crate::RequestId;
11
12/// The only W3C `traceparent` version this proxy emits/accepts (`00`).
13const VERSION: &str = "00";
14/// Length of a well-formed `traceparent`: `00-<32hex>-<16hex>-<2hex>`.
15const TRACEPARENT_LEN: usize = 2 + 1 + 32 + 1 + 16 + 1 + 2;
16
17/// A W3C trace context: the distributed-trace identity the proxy propagates
18/// downstream. Continued from an incoming `traceparent` when present (preserving
19/// the `trace_id` so the trace stays connected end-to-end), or minted as a new
20/// root when absent. Either way a fresh `span_id` identifies *this* hop, so the
21/// upstream call is recorded as a child of the proxy's span.
22///
23/// It also retains the incoming parent's span id (when continuing a trace), so
24/// the proxy's own emitted span can be recorded as a child of the caller's span;
25/// a freshly minted root has no parent.
26///
27/// Not `Copy`: it carries an optional owned `tracestate` (the vendor list the
28/// proxy forwards verbatim), so it is cloned where a batch fans one context
29/// across many ops.
30#[derive(Clone, PartialEq, Eq, Debug)]
31pub struct TraceContext {
32    trace_id: [u8; 16],
33    span_id: [u8; 8],
34    /// The caller's span id, if this context continues an incoming trace, the
35    /// parent the proxy's own span nests under. `None` for a minted root.
36    parent_span_id: Option<[u8; 8]>,
37    /// The incoming W3C `tracestate` (vendor key-value list), forwarded verbatim
38    /// to downstream calls. The proxy adds no entry of its own; only present when
39    /// continuing a trace and the value is within spec bounds.
40    tracestate: Option<String>,
41    sampled: bool,
42}
43
44/// W3C caps `tracestate` at 512 bytes; a longer value is dropped rather than
45/// forwarded, so the header cannot be used to amplify traffic downstream.
46const MAX_TRACESTATE_LEN: usize = 512;
47
48impl TraceContext {
49    /// Continues `incoming_traceparent` if it is present and well-formed, else
50    /// mints a new root trace. A fresh `span_id` for this hop is always derived
51    /// from `request`, so the downstream call chains under the proxy's span.
52    ///
53    /// `incoming_tracestate` (the W3C vendor list) is forwarded verbatim, but only
54    /// when continuing a trace and only when within spec bounds, a `tracestate`
55    /// without a valid `traceparent` is meaningless and is dropped.
56    #[must_use]
57    pub fn propagate(
58        incoming_traceparent: Option<&str>,
59        incoming_tracestate: Option<&str>,
60        request: &RequestId,
61    ) -> Self {
62        Self::propagate_with_b3(incoming_traceparent, incoming_tracestate, None, request)
63    }
64
65    /// Like [`propagate`](Self::propagate), but also continues a caller that
66    /// arrived with a **B3** context (Zipkin/Istio, `b3` single-header form) when
67    /// no W3C `traceparent` is present. W3C wins when both are supplied. This keeps
68    /// the trace connected end to end for a B3-native client even though the proxy
69    /// itself speaks W3C downstream: the caller's `trace_id` is preserved, so the
70    /// proxy's exported span shares the client's trace rather than starting a new
71    /// root. B3 carries no `tracestate`, so a B3-continued context forwards none.
72    #[must_use]
73    pub fn propagate_with_b3(
74        incoming_traceparent: Option<&str>,
75        incoming_tracestate: Option<&str>,
76        incoming_b3: Option<&str>,
77        request: &RequestId,
78    ) -> Self {
79        let from_w3c = incoming_traceparent.and_then(Self::parse);
80        // W3C first; fall back to B3 only when there is no usable `traceparent`.
81        let parent = from_w3c
82            .clone()
83            .or_else(|| incoming_b3.and_then(Self::parse_b3));
84        match parent {
85            // Continue the caller's trace: keep its trace_id and sampling, but
86            // present our own span as the parent of the downstream call, and
87            // remember the caller's span as our own parent.
88            Some(parent) => Self {
89                trace_id: parent.trace_id,
90                span_id: derive8(request, SPAN_SEED),
91                parent_span_id: Some(parent.span_id),
92                // `tracestate` is a W3C concept; only forward it when the context
93                // was continued from a `traceparent`, never from B3.
94                tracestate: if from_w3c.is_some() {
95                    sanitize_tracestate(incoming_tracestate)
96                } else {
97                    None
98                },
99                sampled: parent.sampled,
100            },
101            // No usable upstream context: this request is the trace root. Sample
102            // it so the trace is actually useful to whoever collects it.
103            None => Self {
104                trace_id: derive16(request),
105                span_id: derive8(request, SPAN_SEED),
106                parent_span_id: None,
107                tracestate: None,
108                sampled: true,
109            },
110        }
111    }
112
113    /// Parses a **B3** single-header value (`{trace}-{span}[-{sampled}[-{parent}]]`,
114    /// the Zipkin/Istio form). The trace id is 128- or 64-bit (32 or 16 hex, the
115    /// 64-bit form right-aligned into 128 bits, per the B3 spec); the span id is
116    /// 64-bit (16 hex). A sampling-only `b3` (`0`/`1`/`d` with no ids) carries no
117    /// trace to continue and returns `None`, as does any malformed or all-zero id.
118    #[must_use]
119    pub fn parse_b3(value: &str) -> Option<Self> {
120        let mut parts = value.split('-');
121        let trace_hex = parts.next()?;
122        // No span id ⇒ a sampling-only `b3` (e.g. `b3: 0`); nothing to continue.
123        let span_hex = parts.next()?;
124        let sampled = match parts.next() {
125            None | Some("1" | "d") => true,
126            Some("0") => false,
127            Some(_) => return None,
128        };
129        // An optional parent span id may follow; the proxy does not use it (it
130        // becomes the proxy's parent only via `propagate`), but reject extras.
131        if parts.clone().count() > 1 {
132            return None;
133        }
134        let mut trace_id = [0u8; 16];
135        match trace_hex.len() {
136            32 => decode_hex(trace_hex, &mut trace_id)?,
137            // 64-bit trace id: right-align into the low 8 bytes (B3 §128-bit).
138            16 => decode_hex(trace_hex, &mut trace_id[8..])?,
139            _ => return None,
140        }
141        let mut span_id = [0u8; 8];
142        if span_hex.len() != 16 {
143            return None;
144        }
145        decode_hex(span_hex, &mut span_id)?;
146        if trace_id == [0u8; 16] || span_id == [0u8; 8] {
147            return None;
148        }
149        Some(Self {
150            trace_id,
151            span_id,
152            parent_span_id: None,
153            tracestate: None,
154            sampled,
155        })
156    }
157
158    /// Parses a W3C `traceparent` value (`00-<32hex>-<16hex>-<2hex>`). Returns
159    /// `None` if it is malformed, an unsupported version, or has an all-zero
160    /// trace/span id (which the spec forbids), the caller then mints a root.
161    #[must_use]
162    pub fn parse(value: &str) -> Option<Self> {
163        if value.len() != TRACEPARENT_LEN {
164            return None;
165        }
166        let mut parts = value.split('-');
167        let version = parts.next()?;
168        let trace_hex = parts.next()?;
169        let span_hex = parts.next()?;
170        let flags_hex = parts.next()?;
171        if parts.next().is_some() || version != VERSION {
172            return None;
173        }
174        let mut trace_id = [0u8; 16];
175        let mut span_id = [0u8; 8];
176        decode_hex(trace_hex, &mut trace_id)?;
177        decode_hex(span_hex, &mut span_id)?;
178        let flags = {
179            let mut b = [0u8; 1];
180            decode_hex(flags_hex, &mut b)?;
181            b[0]
182        };
183        // All-zero ids are invalid per the W3C spec.
184        if trace_id == [0u8; 16] || span_id == [0u8; 8] {
185            return None;
186        }
187        Some(Self {
188            trace_id,
189            span_id,
190            // A parsed header represents the caller itself; the proxy-relative
191            // parent link and forwarded tracestate are set only on `propagate`.
192            parent_span_id: None,
193            tracestate: None,
194            sampled: flags & 0x01 != 0,
195        })
196    }
197
198    /// The `traceparent` header value to send to the upstream.
199    #[must_use]
200    pub fn to_traceparent(&self) -> String {
201        let mut out = String::with_capacity(TRACEPARENT_LEN);
202        out.push_str(VERSION);
203        out.push('-');
204        push_hex(&mut out, &self.trace_id);
205        out.push('-');
206        push_hex(&mut out, &self.span_id);
207        out.push('-');
208        push_hex(&mut out, &[u8::from(self.sampled)]);
209        out
210    }
211
212    /// The 32-hex trace id, for correlating this request's logs / `/debug/explain`
213    /// with the distributed trace. An identifier, never a value.
214    #[must_use]
215    pub fn trace_id_hex(&self) -> String {
216        let mut out = String::with_capacity(32);
217        push_hex(&mut out, &self.trace_id);
218        out
219    }
220
221    /// The 16-hex span id of the proxy's hop, the id presented as the parent to
222    /// downstream calls, and therefore the id of the span the proxy must emit so
223    /// the upstream's spans nest under it.
224    #[must_use]
225    pub fn span_id_hex(&self) -> String {
226        let mut out = String::with_capacity(16);
227        push_hex(&mut out, &self.span_id);
228        out
229    }
230
231    /// The 16-hex span id of the **caller's** span, the parent the proxy's own
232    /// span nests under, or `None` when this context is a freshly minted root
233    /// (no incoming `traceparent`).
234    #[must_use]
235    pub fn parent_span_id_hex(&self) -> Option<String> {
236        self.parent_span_id.map(|id| {
237            let mut out = String::with_capacity(16);
238            push_hex(&mut out, &id);
239            out
240        })
241    }
242
243    /// The W3C `tracestate` value to forward to the upstream, if the request
244    /// carried a valid one, passed through verbatim (the proxy adds no entry).
245    #[must_use]
246    pub fn to_tracestate(&self) -> Option<&str> {
247        self.tracestate.as_deref()
248    }
249
250    /// Whether the trace is sampled (the W3C sampled flag).
251    #[must_use]
252    pub fn sampled(&self) -> bool {
253        self.sampled
254    }
255}
256
257/// Accepts an incoming `tracestate` for verbatim forwarding only if it is
258/// non-empty and within the W3C size cap; otherwise drops it (returns `None`).
259/// The proxy is not a tracing vendor, so it never edits the value, it either
260/// forwards exactly what it received or nothing.
261fn sanitize_tracestate(incoming: Option<&str>) -> Option<String> {
262    incoming
263        .map(str::trim)
264        .filter(|s| !s.is_empty() && s.len() <= MAX_TRACESTATE_LEN)
265        .map(str::to_owned)
266}
267
268/// Distinct FNV seed for span ids, so a request's span id never coincides with
269/// the low 8 bytes of its (root) trace id.
270const SPAN_SEED: u64 = 0x27d4_eb2f_1656_67c5;
271/// FNV-1a 64-bit offset basis (the trace-id seed).
272const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
273/// FNV-1a 64-bit prime.
274const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
275
276/// FNV-1a hash of `bytes` from `seed`.
277fn fnv1a(seed: u64, bytes: &[u8]) -> u64 {
278    let mut h = seed;
279    for &b in bytes {
280        h ^= u64::from(b);
281        h = h.wrapping_mul(FNV_PRIME);
282    }
283    h
284}
285
286/// A random per-process seed mixed into every derived id, so ids stay **unique
287/// across instances and restarts** even though the request id they derive from is
288/// only process-local (and W3C wants span ids effectively random). `RandomState`
289/// is seeded from the OS at process start, randomness without pulling an RNG
290/// crate into `core`. It is constant for the life of the process, so derivation
291/// stays deterministic *within* a process (the same request id yields the same
292/// span on every call, e.g. every op of one bulk request shares the proxy span).
293fn process_seed() -> u64 {
294    use std::hash::{BuildHasher, Hasher};
295    static SEED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
296    *SEED.get_or_init(|| {
297        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
298        h.write_u64(FNV_OFFSET);
299        h.finish()
300    })
301}
302
303/// A 16-byte trace id derived from the request id (two independent hashes),
304/// salted with the process seed (see [`process_seed`]).
305fn derive16(request: &RequestId) -> [u8; 16] {
306    derive16_with(process_seed(), request.as_str().as_bytes())
307}
308
309/// An 8-byte span id derived from the request id with `sub`, salted with the
310/// process seed so a span id is unique across instances.
311fn derive8(request: &RequestId, sub: u64) -> [u8; 8] {
312    let mut out = fnv1a(sub ^ process_seed(), request.as_str().as_bytes()).to_be_bytes();
313    if out == [0u8; 8] {
314        out[7] = 1;
315    }
316    out
317}
318
319/// The seedable core of [`derive16`], split out so the cross-seed uniqueness
320/// invariant is unit-testable (different seeds ⇒ disjoint ids for the same input).
321fn derive16_with(seed: u64, s: &[u8]) -> [u8; 16] {
322    let hi = fnv1a(FNV_OFFSET ^ seed, s).to_be_bytes();
323    let lo = fnv1a(FNV_OFFSET ^ FNV_PRIME ^ seed, s).to_be_bytes();
324    let mut out = [0u8; 16];
325    out[..8].copy_from_slice(&hi);
326    out[8..].copy_from_slice(&lo);
327    if out == [0u8; 16] {
328        out[15] = 1;
329    }
330    out
331}
332
333/// Decodes lowercase/uppercase hex into `out`, requiring exactly `2 * out.len()`
334/// hex digits. Returns `None` on any non-hex byte or length mismatch.
335fn decode_hex(hex: &str, out: &mut [u8]) -> Option<()> {
336    if hex.len() != out.len() * 2 {
337        return None;
338    }
339    for (i, byte) in out.iter_mut().enumerate() {
340        let hi = hex_val(hex.as_bytes()[i * 2])?;
341        let lo = hex_val(hex.as_bytes()[i * 2 + 1])?;
342        *byte = (hi << 4) | lo;
343    }
344    Some(())
345}
346
347/// The value of a single hex digit, or `None` if it is not one.
348fn hex_val(c: u8) -> Option<u8> {
349    match c {
350        b'0'..=b'9' => Some(c - b'0'),
351        b'a'..=b'f' => Some(c - b'a' + 10),
352        b'A'..=b'F' => Some(c - b'A' + 10),
353        _ => None,
354    }
355}
356
357/// Appends the lowercase hex of `bytes` to `out`.
358fn push_hex(out: &mut String, bytes: &[u8]) {
359    const DIGITS: &[u8; 16] = b"0123456789abcdef";
360    for &b in bytes {
361        out.push(DIGITS[(b >> 4) as usize] as char);
362        out.push(DIGITS[(b & 0x0f) as usize] as char);
363    }
364}
365
366#[cfg(test)]
367#[path = "trace_tests.rs"]
368mod tests;