Skip to main content

weida_core/
trace.rs

1//! W3C Trace Context propagation.
2//!
3//! v0 propagates `traceparent`/`tracestate` in transfer metadata and logs the
4//! ids through `tracing` (master doc ยง58); the OpenTelemetry SDK is wired up in
5//! the observability phase. This module is the codec only: it never generates
6//! identifiers, because randomness belongs to the runtime, not to the model.
7
8use std::fmt;
9
10/// Wire cap for the DATA `traceparent` field, in bytes.
11pub const MAX_TRACEPARENT_BYTES: usize = 128;
12
13/// Wire cap for the DATA `tracestate` field, in bytes.
14pub const MAX_TRACESTATE_BYTES: usize = 512;
15
16/// Length of a `traceparent` value for version `00`.
17const TRACEPARENT_LEN: usize = 55;
18
19/// The `sampled` flag bit.
20pub const FLAG_SAMPLED: u8 = 0x01;
21
22/// A W3C Trace Context `traceparent`.
23#[derive(Clone, Copy, PartialEq, Eq, Hash)]
24pub struct TraceContext {
25    /// 16-byte trace identifier; never all zero.
26    pub trace_id: [u8; 16],
27    /// 8-byte span identifier of the sender's span; never all zero.
28    pub span_id: [u8; 8],
29    /// Trace flags byte; bit 0 is `sampled`.
30    pub flags: u8,
31}
32
33/// Why a `traceparent` value was rejected.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum TraceError {
36    /// Not exactly 55 bytes (the only length defined for version `00`).
37    Length,
38    /// Hyphens are not at offsets 2, 35 and 52.
39    Layout,
40    /// A field contained a byte that is not a lowercase hex digit.
41    NotHex,
42    /// Version `ff` is forbidden by the specification.
43    ForbiddenVersion,
44    /// `trace_id` or `span_id` was all zero.
45    ZeroId,
46}
47
48impl fmt::Display for TraceError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            TraceError::Length => f.write_str("traceparent must be exactly 55 bytes"),
52            TraceError::Layout => f.write_str("traceparent field layout is malformed"),
53            TraceError::NotHex => f.write_str("traceparent contains a non-hex-digit byte"),
54            TraceError::ForbiddenVersion => f.write_str("traceparent version ff is forbidden"),
55            TraceError::ZeroId => f.write_str("traceparent trace-id/span-id must not be all zero"),
56        }
57    }
58}
59
60impl std::error::Error for TraceError {}
61
62const fn hex_val(b: u8) -> Option<u8> {
63    match b {
64        b'0'..=b'9' => Some(b - b'0'),
65        // The specification mandates lowercase; uppercase is rejected rather
66        // than normalised, because hostile input gets no leniency.
67        b'a'..=b'f' => Some(b - b'a' + 10),
68        _ => None,
69    }
70}
71
72fn decode_hex(src: &[u8], dst: &mut [u8]) -> Result<(), TraceError> {
73    debug_assert_eq!(src.len(), dst.len() * 2);
74    // `as_chunks` over `chunks_exact`: the pair width is a constant, so the
75    // compiler gets `[u8; 2]` and the indexing below needs no bounds checks.
76    let (pairs, _remainder) = src.as_chunks::<2>();
77    for (out, &[hi, lo]) in dst.iter_mut().zip(pairs) {
78        let hi = hex_val(hi).ok_or(TraceError::NotHex)?;
79        let lo = hex_val(lo).ok_or(TraceError::NotHex)?;
80        *out = (hi << 4) | lo;
81    }
82    Ok(())
83}
84
85fn write_hex(f: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result {
86    const HEX: &[u8; 16] = b"0123456789abcdef";
87    for b in bytes {
88        f.write_str(
89            core::str::from_utf8(&[HEX[(*b >> 4) as usize], HEX[(*b & 0x0f) as usize]])
90                .expect("hex digits are ascii"),
91        )?;
92    }
93    Ok(())
94}
95
96impl TraceContext {
97    /// Builds a context from raw identifiers.
98    ///
99    /// Returns `None` if either identifier is all zero, which the W3C
100    /// specification forbids.
101    pub const fn new(trace_id: [u8; 16], span_id: [u8; 8], flags: u8) -> Option<TraceContext> {
102        if is_zero_16(&trace_id) || is_zero_8(&span_id) {
103            return None;
104        }
105        Some(TraceContext {
106            trace_id,
107            span_id,
108            flags,
109        })
110    }
111
112    /// Parses a `traceparent` header value.
113    ///
114    /// Only version `00` layout is accepted: exactly 55 bytes,
115    /// `vv-<32 hex>-<16 hex>-<2 hex>`, lowercase hex, version not `ff`, neither
116    /// identifier all zero.
117    pub fn parse_traceparent(value: &str) -> Result<TraceContext, TraceError> {
118        let b = value.as_bytes();
119        if b.len() != TRACEPARENT_LEN {
120            return Err(TraceError::Length);
121        }
122        if b[2] != b'-' || b[35] != b'-' || b[52] != b'-' {
123            return Err(TraceError::Layout);
124        }
125
126        let mut version = [0u8; 1];
127        decode_hex(&b[0..2], &mut version)?;
128        if version[0] == 0xff {
129            return Err(TraceError::ForbiddenVersion);
130        }
131
132        let mut trace_id = [0u8; 16];
133        decode_hex(&b[3..35], &mut trace_id)?;
134        let mut span_id = [0u8; 8];
135        decode_hex(&b[36..52], &mut span_id)?;
136        let mut flags = [0u8; 1];
137        decode_hex(&b[53..55], &mut flags)?;
138
139        TraceContext::new(trace_id, span_id, flags[0]).ok_or(TraceError::ZeroId)
140    }
141
142    /// Formats the context as a version-`00` `traceparent` value.
143    pub fn to_traceparent(&self) -> String {
144        self.to_string()
145    }
146
147    /// The trace id as 32 lowercase hex digits, for log fields.
148    pub fn trace_id_hex(&self) -> String {
149        let mut s = String::with_capacity(32);
150        push_hex(&mut s, &self.trace_id);
151        s
152    }
153
154    /// The span id as 16 lowercase hex digits, for log fields.
155    pub fn span_id_hex(&self) -> String {
156        let mut s = String::with_capacity(16);
157        push_hex(&mut s, &self.span_id);
158        s
159    }
160
161    /// True if the `sampled` flag is set.
162    pub const fn is_sampled(&self) -> bool {
163        self.flags & FLAG_SAMPLED != 0
164    }
165
166    /// Same trace, new span. Returns `None` for an all-zero span id.
167    pub const fn with_span_id(&self, span_id: [u8; 8]) -> Option<TraceContext> {
168        TraceContext::new(self.trace_id, span_id, self.flags)
169    }
170}
171
172const fn is_zero_16(v: &[u8; 16]) -> bool {
173    let mut i = 0;
174    while i < 16 {
175        if v[i] != 0 {
176            return false;
177        }
178        i += 1;
179    }
180    true
181}
182
183const fn is_zero_8(v: &[u8; 8]) -> bool {
184    let mut i = 0;
185    while i < 8 {
186        if v[i] != 0 {
187            return false;
188        }
189        i += 1;
190    }
191    true
192}
193
194fn push_hex(s: &mut String, bytes: &[u8]) {
195    const HEX: &[u8; 16] = b"0123456789abcdef";
196    for b in bytes {
197        s.push(HEX[(*b >> 4) as usize] as char);
198        s.push(HEX[(*b & 0x0f) as usize] as char);
199    }
200}
201
202impl fmt::Display for TraceContext {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        f.write_str("00-")?;
205        write_hex(f, &self.trace_id)?;
206        f.write_str("-")?;
207        write_hex(f, &self.span_id)?;
208        f.write_str("-")?;
209        write_hex(f, &[self.flags])
210    }
211}
212
213impl fmt::Debug for TraceContext {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(f, "TraceContext({self})")
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    const VALID: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
224
225    #[test]
226    fn parses_the_w3c_example() {
227        let t = TraceContext::parse_traceparent(VALID).unwrap();
228        assert_eq!(t.trace_id_hex(), "4bf92f3577b34da6a3ce929d0e0e4736");
229        assert_eq!(t.span_id_hex(), "00f067aa0ba902b7");
230        assert_eq!(t.flags, 1);
231        assert!(t.is_sampled());
232    }
233
234    #[test]
235    fn format_parse_roundtrip() {
236        let t = TraceContext::parse_traceparent(VALID).unwrap();
237        assert_eq!(t.to_traceparent(), VALID);
238        assert_eq!(
239            TraceContext::parse_traceparent(&t.to_traceparent()).unwrap(),
240            t
241        );
242    }
243
244    #[test]
245    fn formats_a_cleared_flags_byte() {
246        let t = TraceContext::new([0x11; 16], [0x22; 8], 0).unwrap();
247        assert_eq!(
248            t.to_traceparent(),
249            "00-11111111111111111111111111111111-2222222222222222-00"
250        );
251        assert!(!t.is_sampled());
252    }
253
254    #[test]
255    fn rejects_invalid_values() {
256        let cases = [
257            (
258                "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
259                TraceError::ForbiddenVersion,
260            ),
261            (
262                "00-00000000000000000000000000000000-00f067aa0ba902b7-01",
263                TraceError::ZeroId,
264            ),
265            (
266                "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01",
267                TraceError::ZeroId,
268            ),
269            (
270                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-0",
271                TraceError::Length,
272            ),
273            (
274                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-011",
275                TraceError::Length,
276            ),
277            ("", TraceError::Length),
278            (
279                "00_4bf92f3577b34da6a3ce929d0e0e4736_00f067aa0ba902b7_01",
280                TraceError::Layout,
281            ),
282            (
283                "00-4bf92f3577b34da6a3ce929d0e0e4736+00f067aa0ba902b7-01",
284                TraceError::Layout,
285            ),
286            (
287                "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",
288                TraceError::NotHex,
289            ),
290            (
291                "00-4bf92f3577b34da6a3ce929d0e0e473g-00f067aa0ba902b7-01",
292                TraceError::NotHex,
293            ),
294        ];
295        for (input, want) in cases {
296            assert_eq!(
297                TraceContext::parse_traceparent(input),
298                Err(want),
299                "input {input:?}"
300            );
301        }
302    }
303
304    #[test]
305    fn future_versions_parse_with_the_v00_layout() {
306        // Version 01 with the version-00 layout is still readable; only `ff`
307        // is forbidden outright.
308        let t = TraceContext::parse_traceparent(
309            "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
310        )
311        .unwrap();
312        assert_eq!(t.span_id_hex(), "00f067aa0ba902b7");
313    }
314
315    #[test]
316    fn formatted_value_fits_the_wire_cap() {
317        let t = TraceContext::parse_traceparent(VALID).unwrap();
318        assert!(t.to_traceparent().len() <= MAX_TRACEPARENT_BYTES);
319        assert_eq!(t.to_traceparent().len(), TRACEPARENT_LEN);
320    }
321
322    #[test]
323    fn zero_ids_are_unconstructable() {
324        assert!(TraceContext::new([0; 16], [1; 8], 0).is_none());
325        assert!(TraceContext::new([1; 16], [0; 8], 0).is_none());
326        let t = TraceContext::new([1; 16], [1; 8], 0).unwrap();
327        assert!(t.with_span_id([0; 8]).is_none());
328        assert_eq!(t.with_span_id([9; 8]).unwrap().trace_id, [1; 16]);
329    }
330}