Skip to main content

made_core/value_objects/
trace_context.rs

1//! [`TraceContext`] value object — W3C Trace Context `traceparent` header.
2//!
3//! Spec: <https://www.w3.org/TR/trace-context/#traceparent-header>
4//!
5//! Shape: `VERSION-TRACE_ID-PARENT_ID-TRACE_FLAGS`
6//! - `VERSION`      — two hex digits, `"00"` today.
7//! - `TRACE_ID`     — 32 lowercase hex digits, non-zero.
8//! - `PARENT_ID`    — 16 lowercase hex digits (span id), non-zero.
9//! - `TRACE_FLAGS`  — two hex digits.
10//!
11//! MADE does not run an OpenTelemetry SDK itself (yet);
12//! this value object is just the wire-shape helper. Adapters stamp
13//! it on NATS headers so external OTel-aware consumers can correlate.
14//!
15//! Randomly-generated ids are the honest default when no upstream
16//! context is present: `TraceContext::generate()` fills each field
17//! with `uuid::Uuid::new_v4()` bytes projected onto the required
18//! width.
19
20use std::fmt;
21
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::error::DomainError;
26
27/// Fixed width in hex characters per W3C spec.
28const TRACE_ID_HEX_LEN: usize = 32;
29const SPAN_ID_HEX_LEN: usize = 16;
30const FLAGS_HEX_LEN: usize = 2;
31const VERSION_HEX_LEN: usize = 2;
32
33/// The supported version byte. Upstream clients sending a newer
34/// version are tolerated — the extra fields are ignored — but we
35/// only ever format `"00"` on our side.
36const VERSION: &str = "00";
37
38/// W3C `traceparent` shape. All components are lowercase hex.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct TraceContext {
41    trace_id: String,
42    span_id: String,
43    flags: String,
44}
45
46impl TraceContext {
47    /// Parse a `traceparent` header value. Accepts any version but
48    /// only reads the first four dash-separated fields (future
49    /// versions append; we ignore anything past the flags).
50    pub fn parse(header: &str) -> Result<Self, DomainError> {
51        // Spec-mandated total length is ≥ 55 for version 00 (and
52        // exactly 55 for the current version). Reject obvious
53        // truncation early.
54        let parts: Vec<&str> = header.splitn(5, '-').collect();
55        if parts.len() < 4 {
56            return Err(DomainError::InvalidCharacters {
57                field: "traceparent",
58            });
59        }
60        validate_hex(parts[0], VERSION_HEX_LEN, "traceparent.version")?;
61        validate_hex_nonzero(parts[1], TRACE_ID_HEX_LEN, "traceparent.trace_id")?;
62        validate_hex_nonzero(parts[2], SPAN_ID_HEX_LEN, "traceparent.span_id")?;
63        validate_hex(parts[3], FLAGS_HEX_LEN, "traceparent.flags")?;
64        Ok(Self {
65            trace_id: parts[1].to_ascii_lowercase(),
66            span_id: parts[2].to_ascii_lowercase(),
67            flags: parts[3].to_ascii_lowercase(),
68        })
69    }
70
71    /// Build a trace context from caller-supplied hex strings. The
72    /// callers using this are typically crossing a boundary where
73    /// the wire format is already validated; still, we re-check to
74    /// keep the value-object invariant watertight.
75    pub fn new(
76        trace_id: impl Into<String>,
77        span_id: impl Into<String>,
78        flags: impl Into<String>,
79    ) -> Result<Self, DomainError> {
80        let trace_id = trace_id.into().to_ascii_lowercase();
81        let span_id = span_id.into().to_ascii_lowercase();
82        let flags = flags.into().to_ascii_lowercase();
83        validate_hex_nonzero(&trace_id, TRACE_ID_HEX_LEN, "traceparent.trace_id")?;
84        validate_hex_nonzero(&span_id, SPAN_ID_HEX_LEN, "traceparent.span_id")?;
85        validate_hex(&flags, FLAGS_HEX_LEN, "traceparent.flags")?;
86        Ok(Self {
87            trace_id,
88            span_id,
89            flags,
90        })
91    }
92
93    /// Generate a fresh context with random ids and flags = `"01"`
94    /// (sampled). Used when MADE originates a trace
95    /// (no upstream traceparent available).
96    #[must_use]
97    pub fn generate() -> Self {
98        let trace_uuid = Uuid::new_v4();
99        let span_bytes = Uuid::new_v4().into_bytes();
100        let trace_id = hex_encode(trace_uuid.as_bytes());
101        // Span id is 8 bytes (16 hex); take the leading 8 of a v4 uuid.
102        let mut span_id = hex_encode(&span_bytes[..8]);
103        // Guarantee non-zero — vanishingly unlikely but the W3C
104        // spec explicitly forbids all-zero ids.
105        if span_id.bytes().all(|c| c == b'0') {
106            span_id.replace_range(0..1, "1");
107        }
108        Self {
109            trace_id,
110            span_id,
111            flags: "01".to_owned(),
112        }
113    }
114
115    #[must_use]
116    pub fn trace_id(&self) -> &str {
117        &self.trace_id
118    }
119
120    #[must_use]
121    pub fn span_id(&self) -> &str {
122        &self.span_id
123    }
124
125    #[must_use]
126    pub fn flags(&self) -> &str {
127        &self.flags
128    }
129
130    /// Serialize back to the `traceparent` header format.
131    #[must_use]
132    pub fn to_header(&self) -> String {
133        format!(
134            "{VERSION}-{}-{}-{}",
135            self.trace_id, self.span_id, self.flags
136        )
137    }
138}
139
140impl fmt::Display for TraceContext {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str(&self.to_header())
143    }
144}
145
146fn validate_hex(s: &str, expected_len: usize, field: &'static str) -> Result<(), DomainError> {
147    if s.len() != expected_len {
148        return Err(DomainError::FieldTooLong {
149            field,
150            actual: s.len(),
151            max: expected_len,
152        });
153    }
154    if !s.bytes().all(|c| c.is_ascii_hexdigit()) {
155        return Err(DomainError::InvalidCharacters { field });
156    }
157    Ok(())
158}
159
160fn validate_hex_nonzero(
161    s: &str,
162    expected_len: usize,
163    field: &'static str,
164) -> Result<(), DomainError> {
165    validate_hex(s, expected_len, field)?;
166    if s.bytes().all(|c| c == b'0') {
167        return Err(DomainError::InvariantViolated {
168            reason: "traceparent: trace_id and span_id must not be all zeros",
169        });
170    }
171    Ok(())
172}
173
174fn hex_encode(bytes: &[u8]) -> String {
175    use std::fmt::Write as _;
176    let mut out = String::with_capacity(bytes.len() * 2);
177    for byte in bytes {
178        // write! into a String is infallible.
179        write!(out, "{byte:02x}").unwrap();
180    }
181    out
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    const SAMPLE: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
189
190    #[test]
191    fn parse_accepts_the_w3c_example() {
192        let ctx = TraceContext::parse(SAMPLE).unwrap();
193        assert_eq!(ctx.trace_id(), "0af7651916cd43dd8448eb211c80319c");
194        assert_eq!(ctx.span_id(), "b7ad6b7169203331");
195        assert_eq!(ctx.flags(), "01");
196    }
197
198    #[test]
199    fn to_header_roundtrips() {
200        let ctx = TraceContext::parse(SAMPLE).unwrap();
201        assert_eq!(ctx.to_header(), SAMPLE);
202    }
203
204    #[test]
205    fn parse_is_case_insensitive_but_stores_lowercase() {
206        let upper = "00-0AF7651916CD43DD8448EB211C80319C-B7AD6B7169203331-01";
207        let ctx = TraceContext::parse(upper).unwrap();
208        assert_eq!(ctx.trace_id(), "0af7651916cd43dd8448eb211c80319c");
209        assert_eq!(ctx.span_id(), "b7ad6b7169203331");
210    }
211
212    #[test]
213    fn parse_tolerates_trailing_fields_from_future_versions() {
214        // Spec says unknown trailing fields are ignored on an
215        // unknown version; we apply the same leniency on version
216        // 00 too to keep behaviour uniform.
217        let extended = format!("{SAMPLE}-extra-future-stuff");
218        let ctx = TraceContext::parse(&extended).unwrap();
219        assert_eq!(ctx.trace_id(), "0af7651916cd43dd8448eb211c80319c");
220    }
221
222    #[test]
223    fn parse_rejects_wrong_trace_id_length() {
224        let bad = "00-0af7651916cd43dd8448eb211c80319-b7ad6b7169203331-01"; // 31 hex
225        let err = TraceContext::parse(bad).unwrap_err();
226        assert!(matches!(err, DomainError::FieldTooLong { .. }));
227    }
228
229    #[test]
230    fn parse_rejects_all_zero_trace_id() {
231        let bad = "00-00000000000000000000000000000000-b7ad6b7169203331-01";
232        let err = TraceContext::parse(bad).unwrap_err();
233        assert!(matches!(err, DomainError::InvariantViolated { .. }));
234    }
235
236    #[test]
237    fn parse_rejects_all_zero_span_id() {
238        let bad = "00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01";
239        let err = TraceContext::parse(bad).unwrap_err();
240        assert!(matches!(err, DomainError::InvariantViolated { .. }));
241    }
242
243    #[test]
244    fn parse_rejects_non_hex_characters() {
245        let bad = "00-gggggggggggggggggggggggggggggggg-b7ad6b7169203331-01";
246        let err = TraceContext::parse(bad).unwrap_err();
247        assert!(matches!(err, DomainError::InvalidCharacters { .. }));
248    }
249
250    #[test]
251    fn parse_rejects_truncated_header() {
252        let err = TraceContext::parse("00-0af76").unwrap_err();
253        assert!(matches!(err, DomainError::InvalidCharacters { .. }));
254    }
255
256    #[test]
257    fn generate_produces_valid_parseable_context() {
258        let ctx = TraceContext::generate();
259        let parsed = TraceContext::parse(&ctx.to_header()).unwrap();
260        assert_eq!(parsed, ctx);
261        assert_eq!(ctx.flags(), "01");
262        assert_eq!(ctx.trace_id().len(), TRACE_ID_HEX_LEN);
263        assert_eq!(ctx.span_id().len(), SPAN_ID_HEX_LEN);
264    }
265
266    #[test]
267    fn generate_produces_distinct_ids_on_successive_calls() {
268        let a = TraceContext::generate();
269        let b = TraceContext::generate();
270        assert_ne!(a.trace_id(), b.trace_id());
271    }
272
273    #[test]
274    fn display_matches_to_header() {
275        let ctx = TraceContext::parse(SAMPLE).unwrap();
276        assert_eq!(format!("{ctx}"), SAMPLE);
277    }
278
279    #[test]
280    fn serde_roundtrip_preserves_fields() {
281        let ctx = TraceContext::parse(SAMPLE).unwrap();
282        let json = serde_json::to_string(&ctx).unwrap();
283        let back: TraceContext = serde_json::from_str(&json).unwrap();
284        assert_eq!(ctx, back);
285    }
286}