Skip to main content

postrust_core/
trace.rs

1//! W3C trace context, and the request identifier that travels beside it.
2//!
3//! A request that arrives with a `traceparent` header is part of a trace
4//! somebody else started: a gateway, a client library, another service. Its
5//! trace id is kept so the lines this server logs -- and, once spans are
6//! exported, the spans themselves -- join that trace rather than starting an
7//! unrelated one. A request without one starts a trace of its own.
8//!
9//! Hand-written rather than taken from the `opentelemetry` crates, which this
10//! side does not otherwise need: parsing `traceparent` is a fixed-width format
11//! of four fields, and the rules for refusing one are short enough to read
12//! here. See <https://www.w3.org/TR/trace-context/>.
13//!
14//! Nothing here generates an identifier. That needs randomness, and where it
15//! comes from is a host's business -- the server has a thread to keep a
16//! generator on, a Cloudflare Worker has the JavaScript runtime's. So this
17//! module only parses, validates and formats, and builds on wasm32 as it does
18//! everywhere else.
19
20use std::fmt;
21
22/// A trace id: sixteen bytes, never all zero.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24pub struct TraceId([u8; 16]);
25
26impl TraceId {
27    /// The id these bytes spell, or `None` for all zeroes, which the
28    /// specification reserves to mean "no trace".
29    pub fn from_bytes(bytes: [u8; 16]) -> Option<Self> {
30        (bytes != [0; 16]).then_some(Self(bytes))
31    }
32
33    /// The id's bytes.
34    pub fn to_bytes(self) -> [u8; 16] {
35        self.0
36    }
37}
38
39// Formatted when a subscriber asks for it, not before: a span records this as
40// `Display`, so a request whose span nobody is listening to never pays for the
41// thirty-two characters.
42impl fmt::Display for TraceId {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write_hex(f, &self.0)
45    }
46}
47
48/// A span id: eight bytes, never all zero. Here, the id of the caller's span
49/// that this request is a child of.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
51pub struct SpanId([u8; 8]);
52
53impl SpanId {
54    /// The id these bytes spell, or `None` for all zeroes.
55    pub fn from_bytes(bytes: [u8; 8]) -> Option<Self> {
56        (bytes != [0; 8]).then_some(Self(bytes))
57    }
58
59    /// The id's bytes.
60    pub fn to_bytes(self) -> [u8; 8] {
61        self.0
62    }
63}
64
65impl fmt::Display for SpanId {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write_hex(f, &self.0)
68    }
69}
70
71fn write_hex(f: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result {
72    const HEX: &[u8; 16] = b"0123456789abcdef";
73    // Sixteen bytes at most, so thirty-two characters on the stack.
74    let mut buf = [0u8; 32];
75    for (i, b) in bytes.iter().enumerate() {
76        buf[i * 2] = HEX[(b >> 4) as usize];
77        buf[i * 2 + 1] = HEX[(b & 0x0f) as usize];
78    }
79    // Only ASCII hex digits were written.
80    f.write_str(std::str::from_utf8(&buf[..bytes.len() * 2]).map_err(|_| fmt::Error)?)
81}
82
83/// The trace a request belongs to.
84///
85/// Put in the request's extensions by the host before the request reaches the
86/// REST pipeline or GraphQL, so anything downstream can find it without
87/// parsing the headers again.
88#[derive(Clone, Debug, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct TraceContext {
91    /// The trace the request is part of.
92    pub trace_id: TraceId,
93    /// The caller's span, when the request said which it was. `None` when the
94    /// trace started here.
95    pub parent_id: Option<SpanId>,
96    /// `trace-flags` as the caller sent them; bit 0 is "sampled". Zero when
97    /// the trace started here.
98    pub flags: u8,
99    /// `tracestate`, carried as it came, for whatever propagates the trace on.
100    /// Only kept beside a `traceparent` that parsed: the specification has it
101    /// mean nothing on its own.
102    pub tracestate: Option<http::HeaderValue>,
103}
104
105impl TraceContext {
106    /// A trace that starts with this request.
107    pub fn root(trace_id: TraceId) -> Self {
108        Self {
109            trace_id,
110            parent_id: None,
111            flags: 0,
112            tracestate: None,
113        }
114    }
115
116    /// The trace the request's `traceparent` and `tracestate` name, or `None`
117    /// when there is no `traceparent` or it cannot be used.
118    ///
119    /// More than one `traceparent` is refused rather than one of them picked:
120    /// the specification leaves no way to tell which is meant. Several
121    /// `tracestate` headers are one list split across lines, and are joined.
122    pub fn from_headers(headers: &http::HeaderMap) -> Option<Self> {
123        let mut values = headers.get_all("traceparent").iter();
124        let traceparent = values.next()?;
125        if values.next().is_some() {
126            return None;
127        }
128        let (trace_id, parent_id, flags) = parse_traceparent(traceparent.as_bytes())?;
129
130        let mut states = headers.get_all("tracestate").iter();
131        let tracestate = match (states.next(), states.next()) {
132            (None, _) => None,
133            (Some(only), None) => Some(only.clone()),
134            // Rare enough that allocating to join them is not worth avoiding.
135            (Some(first), Some(second)) => {
136                let mut joined = first.as_bytes().to_vec();
137                for value in std::iter::once(second).chain(states) {
138                    joined.push(b',');
139                    joined.extend_from_slice(value.as_bytes());
140                }
141                http::HeaderValue::from_bytes(&joined).ok()
142            }
143        };
144
145        Some(Self {
146            trace_id,
147            parent_id: Some(parent_id),
148            flags,
149            tracestate,
150        })
151    }
152
153    /// Whether the caller said this trace is being recorded.
154    pub fn sampled(&self) -> bool {
155        self.flags & 0x01 != 0
156    }
157}
158
159/// Parse a `traceparent` value: `version-traceid-parentid-flags`, as
160/// lowercase hex.
161///
162/// Refused, as the specification says it must be: version `ff`; a trace id or
163/// parent id of all zeroes; uppercase hex; a version-`00` value with anything
164/// after the flags. A version this side does not know is read as `00` would
165/// be, provided what follows the flags starts a new field -- the
166/// specification's provision for versions not yet written.
167pub fn parse_traceparent(value: &[u8]) -> Option<(TraceId, SpanId, u8)> {
168    if value.len() < 55 {
169        return None;
170    }
171    let version = hex_byte(&value[0..2])?;
172    if version == 0xff {
173        return None;
174    }
175    if version == 0x00 && value.len() != 55 {
176        return None;
177    }
178    if value.len() > 55 && value[55] != b'-' {
179        return None;
180    }
181    if value[2] != b'-' || value[35] != b'-' || value[52] != b'-' {
182        return None;
183    }
184
185    let mut trace = [0u8; 16];
186    for (i, byte) in trace.iter_mut().enumerate() {
187        *byte = hex_byte(&value[3 + i * 2..5 + i * 2])?;
188    }
189    let mut parent = [0u8; 8];
190    for (i, byte) in parent.iter_mut().enumerate() {
191        *byte = hex_byte(&value[36 + i * 2..38 + i * 2])?;
192    }
193    let flags = hex_byte(&value[53..55])?;
194
195    Some((
196        TraceId::from_bytes(trace)?,
197        SpanId::from_bytes(parent)?,
198        flags,
199    ))
200}
201
202/// Two lowercase hex digits. Uppercase is refused: the specification spells
203/// every field in lowercase, and a header that does not is not one it wrote.
204fn hex_byte(pair: &[u8]) -> Option<u8> {
205    fn digit(c: u8) -> Option<u8> {
206        match c {
207            b'0'..=b'9' => Some(c - b'0'),
208            b'a'..=b'f' => Some(c - b'a' + 10),
209            _ => None,
210        }
211    }
212    Some((digit(pair[0])? << 4) | digit(pair[1])?)
213}
214
215/// The longest `x-request-id` taken from a client. Long enough for any
216/// identifier scheme in use -- a UUID is 36, a ULID 26, a Heroku or Envoy id
217/// well under this -- and short enough that the header cannot be used to make
218/// every log line carry a payload.
219pub const MAX_REQUEST_ID_LEN: usize = 128;
220
221/// Whether a client's `x-request-id` can be used as given: between one and
222/// [`MAX_REQUEST_ID_LEN`] bytes, every one of them visible ASCII.
223///
224/// Visible ASCII and nothing else, because the id is written into log lines
225/// and echoed back as a header: no spaces to split a log field on, no control
226/// characters, nothing a terminal or a log shipper might interpret.
227pub fn valid_request_id(id: &[u8]) -> bool {
228    (1..=MAX_REQUEST_ID_LEN).contains(&id.len()) && id.iter().all(|b| (0x21..=0x7e).contains(b))
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    const VALID: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
236
237    #[test]
238    fn a_valid_traceparent_parses() {
239        let (trace, parent, flags) = parse_traceparent(VALID.as_bytes()).unwrap();
240        assert_eq!(trace.to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
241        assert_eq!(parent.to_string(), "00f067aa0ba902b7");
242        assert_eq!(flags, 0x01);
243    }
244
245    #[test]
246    fn version_ff_is_refused() {
247        let value = VALID.replacen("00", "ff", 1);
248        assert_eq!(parse_traceparent(value.as_bytes()), None);
249    }
250
251    #[test]
252    fn all_zero_ids_are_refused() {
253        let zero_trace = "00-00000000000000000000000000000000-00f067aa0ba902b7-01";
254        let zero_parent = "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01";
255        assert_eq!(parse_traceparent(zero_trace.as_bytes()), None);
256        assert_eq!(parse_traceparent(zero_parent.as_bytes()), None);
257    }
258
259    #[test]
260    fn malformed_values_are_refused() {
261        for value in [
262            "",
263            "00",
264            // Uppercase hex.
265            "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01",
266            // A separator missing.
267            "00-4bf92f3577b34da6a3ce929d0e0e4736x00f067aa0ba902b7-01",
268            // Not hex.
269            "00-4bf92f3577b34da6a3ce929d0e0e473g-00f067aa0ba902b7-01",
270            // Too short.
271            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1",
272            // Version 00 with something after the flags.
273            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra",
274            // A later version whose trailing data does not start a field.
275            "cc-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01x",
276        ] {
277            assert_eq!(parse_traceparent(value.as_bytes()), None, "{value:?}");
278        }
279    }
280
281    #[test]
282    fn a_later_version_is_read_as_00() {
283        let value = "cc-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-what-the-future-holds";
284        let (trace, _, _) = parse_traceparent(value.as_bytes()).unwrap();
285        assert_eq!(trace.to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
286        // And exactly the version-00 length is fine too.
287        assert!(parse_traceparent(VALID.replacen("00", "01", 1).as_bytes()).is_some());
288    }
289
290    #[test]
291    fn trace_context_comes_from_headers() {
292        let mut headers = http::HeaderMap::new();
293        headers.insert("traceparent", VALID.parse().unwrap());
294        headers.append("tracestate", "rojo=00f067aa0ba902b7".parse().unwrap());
295        headers.append("tracestate", "congo=t61rcWkgMzE".parse().unwrap());
296        let context = TraceContext::from_headers(&headers).unwrap();
297        assert_eq!(
298            context.trace_id.to_string(),
299            "4bf92f3577b34da6a3ce929d0e0e4736"
300        );
301        assert!(context.sampled());
302        assert_eq!(
303            context.tracestate.unwrap(),
304            "rojo=00f067aa0ba902b7,congo=t61rcWkgMzE"
305        );
306    }
307
308    #[test]
309    fn two_traceparents_are_no_traceparent() {
310        let mut headers = http::HeaderMap::new();
311        headers.append("traceparent", VALID.parse().unwrap());
312        headers.append("traceparent", VALID.parse().unwrap());
313        assert_eq!(TraceContext::from_headers(&headers), None);
314    }
315
316    #[test]
317    fn tracestate_alone_means_nothing() {
318        let mut headers = http::HeaderMap::new();
319        headers.insert("tracestate", "rojo=00f067aa0ba902b7".parse().unwrap());
320        assert_eq!(TraceContext::from_headers(&headers), None);
321    }
322
323    #[test]
324    fn request_ids_are_bounded_visible_ascii() {
325        assert!(valid_request_id(b"0f8fad5b-d9cb-469f-a165-70867728950e"));
326        assert!(valid_request_id(b"a"));
327        assert!(valid_request_id(&[b'x'; MAX_REQUEST_ID_LEN]));
328        assert!(!valid_request_id(b""));
329        assert!(!valid_request_id(&[b'x'; MAX_REQUEST_ID_LEN + 1]));
330        assert!(!valid_request_id(b"has space"));
331        assert!(!valid_request_id(b"tab\there"));
332        assert!(!valid_request_id("caf\u{e9}".as_bytes()));
333    }
334}