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;