opentelemetry_lambda_extension/
tracing.rs

1//! X-Ray trace header parsing and W3C trace context conversion.
2//!
3//! This module provides utilities for parsing AWS X-Ray trace headers from the
4//! Lambda Extensions API and converting them to W3C trace context format for
5//! use with OpenTelemetry.
6
7use std::fmt;
8
9/// Parsed X-Ray trace header components.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct XRayTraceHeader {
12    /// The X-Ray Root trace ID (e.g., "1-5759e988-bd862e3fe1be46a994272793").
13    pub root: String,
14    /// The parent segment ID (e.g., "53995c3f42cd8ad8").
15    pub parent: Option<String>,
16    /// Whether sampling is enabled (1) or disabled (0).
17    pub sampled: Option<bool>,
18}
19
20impl XRayTraceHeader {
21    /// Parses an X-Ray trace header string.
22    ///
23    /// The format is: `Root=1-{timestamp}-{random};Parent={parent_id};Sampled={0|1}`
24    ///
25    /// # Arguments
26    ///
27    /// * `header` - The X-Ray trace header string
28    ///
29    /// # Returns
30    ///
31    /// Returns `Some(XRayTraceHeader)` if parsing succeeds, `None` otherwise.
32    ///
33    /// # Examples
34    ///
35    /// ```
36    /// use opentelemetry_lambda_extension::XRayTraceHeader;
37    ///
38    /// let header = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1";
39    /// let parsed = XRayTraceHeader::parse(header).unwrap();
40    /// assert_eq!(parsed.root, "1-5759e988-bd862e3fe1be46a994272793");
41    /// assert_eq!(parsed.parent, Some("53995c3f42cd8ad8".to_string()));
42    /// assert_eq!(parsed.sampled, Some(true));
43    /// ```
44    pub fn parse(header: &str) -> Option<Self> {
45        let mut root = None;
46        let mut parent = None;
47        let mut sampled = None;
48
49        for part in header.split(';') {
50            let part = part.trim();
51            if let Some((key, value)) = part.split_once('=') {
52                match key {
53                    "Root" => root = Some(value.to_string()),
54                    "Parent" => parent = Some(value.to_string()),
55                    "Sampled" => {
56                        sampled = match value {
57                            "1" => Some(true),
58                            "0" => Some(false),
59                            _ => None,
60                        }
61                    }
62                    _ => {} // Ignore unknown keys
63                }
64            }
65        }
66
67        root.map(|root| Self {
68            root,
69            parent,
70            sampled,
71        })
72    }
73
74    /// Converts the X-Ray trace header to W3C trace context format.
75    ///
76    /// Returns a tuple of (trace_id, span_id, trace_flags) suitable for
77    /// use with OpenTelemetry span context.
78    ///
79    /// # Returns
80    ///
81    /// Returns `Some((trace_id, span_id, sampled))` if conversion succeeds.
82    /// - `trace_id` is a 32-character hex string (16 bytes)
83    /// - `span_id` is a 16-character hex string (8 bytes)
84    /// - `sampled` indicates whether the trace is sampled
85    ///
86    /// Returns `None` if the parent is not present or conversion fails.
87    pub fn to_w3c(&self) -> Option<W3CTraceContext> {
88        let parent = self.parent.as_ref()?;
89
90        // X-Ray Root format: 1-{8 char timestamp}-{24 char random}
91        // W3C trace-id: 32 hex chars = 16 bytes
92        // We convert by combining the 8 char timestamp + 24 char random = 32 chars
93        let trace_id = self.xray_root_to_trace_id()?;
94
95        // X-Ray Parent is already a 16 char hex string (8 bytes)
96        // W3C span-id: 16 hex chars = 8 bytes
97        if parent.len() != 16 || !parent.chars().all(|c| c.is_ascii_hexdigit()) {
98            return None;
99        }
100
101        Some(W3CTraceContext {
102            trace_id,
103            span_id: parent.clone(),
104            sampled: self.sampled.unwrap_or(false),
105        })
106    }
107
108    /// Converts X-Ray Root ID to W3C trace ID.
109    ///
110    /// X-Ray Root format: `1-{8 hex chars timestamp}-{24 hex chars random}`
111    /// W3C trace-id: 32 hex characters
112    ///
113    /// We combine timestamp + random to form the trace ID.
114    fn xray_root_to_trace_id(&self) -> Option<String> {
115        let parts: Vec<&str> = self.root.split('-').collect();
116        if parts.len() != 3 {
117            return None;
118        }
119
120        let version = parts[0];
121        let timestamp = parts[1];
122        let random = parts[2];
123
124        // Validate version
125        if version != "1" {
126            return None;
127        }
128
129        // Validate timestamp (8 hex chars)
130        if timestamp.len() != 8 || !timestamp.chars().all(|c| c.is_ascii_hexdigit()) {
131            return None;
132        }
133
134        // Validate random (24 hex chars)
135        if random.len() != 24 || !random.chars().all(|c| c.is_ascii_hexdigit()) {
136            return None;
137        }
138
139        // Combine to form 32-char trace ID
140        Some(format!("{}{}", timestamp, random))
141    }
142
143    /// Returns the raw X-Ray trace header string.
144    pub fn to_header_string(&self) -> String {
145        let mut parts = vec![format!("Root={}", self.root)];
146
147        if let Some(ref parent) = self.parent {
148            parts.push(format!("Parent={}", parent));
149        }
150
151        if let Some(sampled) = self.sampled {
152            parts.push(format!("Sampled={}", if sampled { "1" } else { "0" }));
153        }
154
155        parts.join(";")
156    }
157}
158
159impl fmt::Display for XRayTraceHeader {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(f, "{}", self.to_header_string())
162    }
163}
164
165/// W3C trace context components.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct W3CTraceContext {
168    /// 32-character hex trace ID (16 bytes).
169    pub trace_id: String,
170    /// 16-character hex span ID (8 bytes).
171    pub span_id: String,
172    /// Whether the trace is sampled.
173    pub sampled: bool,
174}
175
176impl W3CTraceContext {
177    /// Converts trace ID to bytes.
178    ///
179    /// Returns a 16-byte array representation of the trace ID.
180    pub fn trace_id_bytes(&self) -> Option<[u8; 16]> {
181        let bytes = hex::decode(&self.trace_id).ok()?;
182        if bytes.len() != 16 {
183            return None;
184        }
185        let mut arr = [0u8; 16];
186        arr.copy_from_slice(&bytes);
187        Some(arr)
188    }
189
190    /// Converts span ID to bytes.
191    ///
192    /// Returns an 8-byte array representation of the span ID.
193    pub fn span_id_bytes(&self) -> Option<[u8; 8]> {
194        let bytes = hex::decode(&self.span_id).ok()?;
195        if bytes.len() != 8 {
196            return None;
197        }
198        let mut arr = [0u8; 8];
199        arr.copy_from_slice(&bytes);
200        Some(arr)
201    }
202
203    /// Returns the W3C traceparent header string.
204    ///
205    /// Format: `{version}-{trace-id}-{parent-id}-{trace-flags}`
206    pub fn to_traceparent(&self) -> String {
207        let flags = if self.sampled { "01" } else { "00" };
208        format!("00-{}-{}-{}", self.trace_id, self.span_id, flags)
209    }
210}
211
212impl fmt::Display for W3CTraceContext {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        write!(f, "{}", self.to_traceparent())
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use proptest::prelude::*;
222
223    fn valid_timestamp() -> impl Strategy<Value = String> {
224        "[0-9a-f]{8}".prop_map(|s| s.to_lowercase())
225    }
226
227    fn valid_random() -> impl Strategy<Value = String> {
228        "[0-9a-f]{24}".prop_map(|s| s.to_lowercase())
229    }
230
231    fn valid_parent() -> impl Strategy<Value = String> {
232        "[0-9a-f]{16}".prop_map(|s| s.to_lowercase())
233    }
234
235    proptest! {
236        #[test]
237        fn parse_roundtrips(
238            timestamp in valid_timestamp(),
239            random in valid_random(),
240            parent in valid_parent(),
241            sampled in prop::bool::ANY
242        ) {
243            let root = format!("1-{}-{}", timestamp, random);
244            let header_str = format!(
245                "Root={};Parent={};Sampled={}",
246                root,
247                parent,
248                if sampled { "1" } else { "0" }
249            );
250
251            let parsed = XRayTraceHeader::parse(&header_str).unwrap();
252
253            prop_assert_eq!(parsed.root, root);
254            prop_assert_eq!(parsed.parent, Some(parent));
255            prop_assert_eq!(parsed.sampled, Some(sampled));
256        }
257
258        #[test]
259        fn w3c_conversion_produces_valid_ids(
260            timestamp in valid_timestamp(),
261            random in valid_random(),
262            parent in valid_parent(),
263        ) {
264            let header = XRayTraceHeader {
265                root: format!("1-{}-{}", timestamp, random),
266                parent: Some(parent.clone()),
267                sampled: Some(true),
268            };
269
270            let w3c = header.to_w3c().unwrap();
271
272            prop_assert_eq!(w3c.trace_id.len(), 32);
273            prop_assert_eq!(w3c.span_id.len(), 16);
274            prop_assert_eq!(w3c.span_id, parent);
275            prop_assert!(w3c.trace_id.chars().all(|c| c.is_ascii_hexdigit()));
276        }
277
278        #[test]
279        fn trace_id_bytes_roundtrips(
280            timestamp in valid_timestamp(),
281            random in valid_random(),
282            parent in valid_parent(),
283        ) {
284            let header = XRayTraceHeader {
285                root: format!("1-{}-{}", timestamp, random),
286                parent: Some(parent),
287                sampled: Some(true),
288            };
289
290            let w3c = header.to_w3c().unwrap();
291            let bytes = w3c.trace_id_bytes().unwrap();
292
293            prop_assert_eq!(bytes.len(), 16);
294            let hex_back = hex::encode(bytes);
295            prop_assert_eq!(hex_back, w3c.trace_id);
296        }
297    }
298
299    #[test]
300    fn test_parse_full_header() {
301        let header = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1";
302        let parsed = XRayTraceHeader::parse(header).unwrap();
303
304        assert_eq!(parsed.root, "1-5759e988-bd862e3fe1be46a994272793");
305        assert_eq!(parsed.parent, Some("53995c3f42cd8ad8".to_string()));
306        assert_eq!(parsed.sampled, Some(true));
307    }
308
309    #[test]
310    fn test_parse_header_unsampled() {
311        let header = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=0";
312        let parsed = XRayTraceHeader::parse(header).unwrap();
313
314        assert_eq!(parsed.sampled, Some(false));
315    }
316
317    #[test]
318    fn test_parse_header_no_parent() {
319        let header = "Root=1-5759e988-bd862e3fe1be46a994272793;Sampled=1";
320        let parsed = XRayTraceHeader::parse(header).unwrap();
321
322        assert_eq!(parsed.root, "1-5759e988-bd862e3fe1be46a994272793");
323        assert!(parsed.parent.is_none());
324        assert_eq!(parsed.sampled, Some(true));
325    }
326
327    #[test]
328    fn test_parse_header_root_only() {
329        let header = "Root=1-5759e988-bd862e3fe1be46a994272793";
330        let parsed = XRayTraceHeader::parse(header).unwrap();
331
332        assert_eq!(parsed.root, "1-5759e988-bd862e3fe1be46a994272793");
333        assert!(parsed.parent.is_none());
334        assert!(parsed.sampled.is_none());
335    }
336
337    #[test]
338    fn test_parse_invalid_header() {
339        assert!(XRayTraceHeader::parse("").is_none());
340        assert!(XRayTraceHeader::parse("Parent=123").is_none());
341        assert!(XRayTraceHeader::parse("invalid").is_none());
342    }
343
344    #[test]
345    fn test_to_w3c() {
346        let header = XRayTraceHeader {
347            root: "1-5759e988-bd862e3fe1be46a994272793".to_string(),
348            parent: Some("53995c3f42cd8ad8".to_string()),
349            sampled: Some(true),
350        };
351
352        let w3c = header.to_w3c().unwrap();
353
354        // trace_id = timestamp (5759e988) + random (bd862e3fe1be46a994272793) = 32 chars
355        assert_eq!(w3c.trace_id, "5759e988bd862e3fe1be46a994272793");
356        assert_eq!(w3c.span_id, "53995c3f42cd8ad8");
357        assert!(w3c.sampled);
358    }
359
360    #[test]
361    fn test_to_w3c_no_parent() {
362        let header = XRayTraceHeader {
363            root: "1-5759e988-bd862e3fe1be46a994272793".to_string(),
364            parent: None,
365            sampled: Some(true),
366        };
367
368        assert!(header.to_w3c().is_none());
369    }
370
371    #[test]
372    fn test_to_w3c_invalid_parent() {
373        let header = XRayTraceHeader {
374            root: "1-5759e988-bd862e3fe1be46a994272793".to_string(),
375            parent: Some("invalid".to_string()),
376            sampled: Some(true),
377        };
378
379        assert!(header.to_w3c().is_none());
380    }
381
382    #[test]
383    fn test_to_w3c_invalid_root() {
384        let header = XRayTraceHeader {
385            root: "invalid-root".to_string(),
386            parent: Some("53995c3f42cd8ad8".to_string()),
387            sampled: Some(true),
388        };
389
390        assert!(header.to_w3c().is_none());
391    }
392
393    #[test]
394    fn test_w3c_to_traceparent() {
395        let ctx = W3CTraceContext {
396            trace_id: "5759e988bd862e3fe1be46a994272793".to_string(),
397            span_id: "53995c3f42cd8ad8".to_string(),
398            sampled: true,
399        };
400
401        assert_eq!(
402            ctx.to_traceparent(),
403            "00-5759e988bd862e3fe1be46a994272793-53995c3f42cd8ad8-01"
404        );
405    }
406
407    #[test]
408    fn test_w3c_to_traceparent_unsampled() {
409        let ctx = W3CTraceContext {
410            trace_id: "5759e988bd862e3fe1be46a994272793".to_string(),
411            span_id: "53995c3f42cd8ad8".to_string(),
412            sampled: false,
413        };
414
415        assert_eq!(
416            ctx.to_traceparent(),
417            "00-5759e988bd862e3fe1be46a994272793-53995c3f42cd8ad8-00"
418        );
419    }
420
421    #[test]
422    fn test_trace_id_bytes() {
423        let ctx = W3CTraceContext {
424            trace_id: "5759e988bd862e3fe1be46a994272793".to_string(),
425            span_id: "53995c3f42cd8ad8".to_string(),
426            sampled: true,
427        };
428
429        let bytes = ctx.trace_id_bytes().unwrap();
430        assert_eq!(bytes.len(), 16);
431        assert_eq!(bytes[0], 0x57);
432        assert_eq!(bytes[1], 0x59);
433    }
434
435    #[test]
436    fn test_span_id_bytes() {
437        let ctx = W3CTraceContext {
438            trace_id: "5759e988bd862e3fe1be46a994272793".to_string(),
439            span_id: "53995c3f42cd8ad8".to_string(),
440            sampled: true,
441        };
442
443        let bytes = ctx.span_id_bytes().unwrap();
444        assert_eq!(bytes.len(), 8);
445        assert_eq!(bytes[0], 0x53);
446        assert_eq!(bytes[1], 0x99);
447    }
448
449    #[test]
450    fn test_to_header_string() {
451        let header = XRayTraceHeader {
452            root: "1-5759e988-bd862e3fe1be46a994272793".to_string(),
453            parent: Some("53995c3f42cd8ad8".to_string()),
454            sampled: Some(true),
455        };
456
457        assert_eq!(
458            header.to_header_string(),
459            "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1"
460        );
461    }
462
463    #[test]
464    fn test_display() {
465        let header = XRayTraceHeader {
466            root: "1-5759e988-bd862e3fe1be46a994272793".to_string(),
467            parent: Some("53995c3f42cd8ad8".to_string()),
468            sampled: Some(true),
469        };
470
471        let s = format!("{}", header);
472        assert!(s.contains("Root="));
473        assert!(s.contains("Parent="));
474        assert!(s.contains("Sampled=1"));
475    }
476}