made_core/value_objects/
trace_context.rs1use std::fmt;
21
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::error::DomainError;
26
27const 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
33const VERSION: &str = "00";
37
38#[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 pub fn parse(header: &str) -> Result<Self, DomainError> {
51 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 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 #[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 let mut span_id = hex_encode(&span_bytes[..8]);
103 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 #[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!(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 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"; 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}