relay_event_schema/protocol/contexts/
trace.rs

1use relay_protocol::{
2    Annotated, Array, Empty, Error, FromValue, HexId, IntoValue, Object, SkipSerialization, Val,
3    Value,
4};
5use serde::Serializer;
6use std::fmt;
7use std::ops::Deref;
8use std::str::FromStr;
9use uuid::Uuid;
10
11use crate::processor::ProcessValue;
12use crate::protocol::{OperationType, OriginType, SpanData, SpanLink, SpanStatus};
13
14/// Represents a W3C Trace Context `trace-id`.
15///
16/// The `trace-id` is a globally unique identifier for a distributed trace,
17/// used to correlate requests across service boundaries.
18///
19/// Format:
20/// - 16-byte array (128 bits), represented as 32-character hexadecimal string
21/// - Example: `"4bf92f3577b34da6a3ce929d0e0e4736"`
22/// - MUST NOT be all zeros (`"00000000000000000000000000000000"`)
23/// - MUST contain only hex digits (`0-9`, `a-f`, `A-F`)
24///
25/// Our implementation allows uppercase hexadecimal characters for backward compatibility, even
26/// though the original spec only allows lowercase hexadecimal characters.
27///
28/// See: <https://www.w3.org/TR/trace-context/#trace-id>
29#[derive(Clone, Copy, Default, PartialEq, Empty, ProcessValue)]
30pub struct TraceId(Uuid);
31
32relay_common::impl_str_serde!(TraceId, "a trace identifier");
33
34impl FromStr for TraceId {
35    type Err = Error;
36
37    fn from_str(s: &str) -> Result<Self, Self::Err> {
38        Uuid::parse_str(s)
39            .map(Into::into)
40            .map_err(|_| Error::invalid("the trace id is not valid"))
41    }
42}
43
44impl TryFrom<&[u8]> for TraceId {
45    type Error = Error;
46
47    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
48        let uuid =
49            Uuid::from_slice(value).map_err(|_| Error::invalid("the trace id is not valid"))?;
50        Ok(Self(uuid))
51    }
52}
53
54impl fmt::Display for TraceId {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.0.as_simple())
57    }
58}
59
60impl fmt::Debug for TraceId {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(f, "TraceId(\"{}\")", self.0.as_simple())
63    }
64}
65
66impl From<Uuid> for TraceId {
67    fn from(uuid: Uuid) -> Self {
68        TraceId(uuid)
69    }
70}
71
72impl Deref for TraceId {
73    type Target = Uuid;
74
75    fn deref(&self) -> &Self::Target {
76        &self.0
77    }
78}
79
80impl FromValue for TraceId {
81    fn from_value(value: Annotated<Value>) -> Annotated<Self>
82    where
83        Self: Sized,
84    {
85        match value {
86            Annotated(Some(Value::String(value)), mut meta) => match value.parse() {
87                Ok(trace_id) => Annotated(Some(trace_id), meta),
88                Err(_) => {
89                    meta.add_error(Error::invalid("not a valid trace id"));
90                    meta.set_original_value(Some(value));
91                    Annotated(None, meta)
92                }
93            },
94            Annotated(None, meta) => Annotated(None, meta),
95            Annotated(Some(value), mut meta) => {
96                meta.add_error(Error::expected("trace id"));
97                meta.set_original_value(Some(value));
98                Annotated(None, meta)
99            }
100        }
101    }
102}
103
104impl IntoValue for TraceId {
105    fn into_value(self) -> Value
106    where
107        Self: Sized,
108    {
109        Value::String(self.to_string())
110    }
111
112    fn serialize_payload<S>(&self, s: S, _behavior: SkipSerialization) -> Result<S::Ok, S::Error>
113    where
114        Self: Sized,
115        S: Serializer,
116    {
117        s.collect_str(self)
118    }
119}
120
121/// A 16-character hex string as described in the W3C trace context spec, stored
122/// internally as an array of 8 bytes.
123#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Ord, PartialOrd)]
124pub struct SpanId([u8; 8]);
125
126relay_common::impl_str_serde!(SpanId, "a span identifier");
127
128impl FromStr for SpanId {
129    type Err = Error;
130
131    fn from_str(s: &str) -> Result<Self, Self::Err> {
132        match u64::from_str_radix(s, 16) {
133            Ok(id) if s.len() == 16 && id > 0 => Ok(Self(id.to_be_bytes())),
134            _ => Err(Error::invalid("not a valid span id")),
135        }
136    }
137}
138
139impl TryFrom<&[u8]> for SpanId {
140    type Error = Error;
141
142    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
143        match <[u8; 8]>::try_from(value) {
144            Ok(bytes) if !bytes.iter().all(|&x| x == 0) => Ok(Self(bytes)),
145            _ => Err(Error::invalid("not a valid span id")),
146        }
147    }
148}
149
150impl fmt::Debug for SpanId {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "SpanId(\"")?;
153        for b in self.0 {
154            write!(f, "{b:02x}")?;
155        }
156        write!(f, "\")")
157    }
158}
159
160impl fmt::Display for SpanId {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        for b in self.0 {
163            write!(f, "{b:02x}")?;
164        }
165        Ok(())
166    }
167}
168
169impl FromValue for SpanId {
170    fn from_value(value: Annotated<Value>) -> Annotated<Self> {
171        match value {
172            Annotated(Some(Value::String(value)), mut meta) => match value.parse() {
173                Ok(span_id) => Annotated::new(span_id),
174                Err(e) => {
175                    meta.add_error(e);
176                    meta.set_original_value(Some(value));
177                    Annotated(None, meta)
178                }
179            },
180            Annotated(None, meta) => Annotated(None, meta),
181            Annotated(Some(value), mut meta) => {
182                meta.add_error(Error::expected("span id"));
183                meta.set_original_value(Some(value));
184                Annotated(None, meta)
185            }
186        }
187    }
188}
189
190impl Empty for SpanId {
191    fn is_empty(&self) -> bool {
192        false
193    }
194}
195
196impl IntoValue for SpanId {
197    fn into_value(self) -> Value
198    where
199        Self: Sized,
200    {
201        Value::String(self.to_string())
202    }
203
204    fn serialize_payload<S>(&self, s: S, _behavior: SkipSerialization) -> Result<S::Ok, S::Error>
205    where
206        Self: Sized,
207        S: serde::Serializer,
208    {
209        s.collect_str(self)
210    }
211}
212
213impl ProcessValue for SpanId {}
214
215impl std::ops::Deref for SpanId {
216    type Target = [u8];
217
218    fn deref(&self) -> &Self::Target {
219        &self.0
220    }
221}
222
223impl<'a> From<&'a SpanId> for Val<'a> {
224    fn from(value: &'a SpanId) -> Self {
225        Val::HexId(HexId(&value.0))
226    }
227}
228
229/// Trace context
230#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
231#[metastructure(process_func = "process_trace_context")]
232pub struct TraceContext {
233    /// The trace ID.
234    #[metastructure(required = true)]
235    pub trace_id: Annotated<TraceId>,
236
237    /// The ID of the span.
238    #[metastructure(required = true)]
239    pub span_id: Annotated<SpanId>,
240
241    /// The ID of the span enclosing this span.
242    pub parent_span_id: Annotated<SpanId>,
243
244    /// Span type (see `OperationType` docs).
245    #[metastructure(max_chars = 128)]
246    pub op: Annotated<OperationType>,
247
248    /// Whether the trace failed or succeeded. Currently only used to indicate status of individual
249    /// transactions.
250    pub status: Annotated<SpanStatus>,
251
252    /// The amount of time in milliseconds spent in this transaction span,
253    /// excluding its immediate child spans.
254    pub exclusive_time: Annotated<f64>,
255
256    /// The client-side sample rate as reported in the envelope's `trace.sample_rate` header.
257    ///
258    /// The server takes this field from envelope headers and writes it back into the event. Clients
259    /// should not ever send this value.
260    pub client_sample_rate: Annotated<f64>,
261
262    /// The origin of the trace indicates what created the trace (see [OriginType] docs).
263    #[metastructure(max_chars = 128, allow_chars = "a-zA-Z0-9_.")]
264    pub origin: Annotated<OriginType>,
265
266    /// Track whether the trace connected to this event has been sampled entirely.
267    ///
268    /// This flag only applies to events with [`Error`] type that have an associated dynamic sampling context.
269    pub sampled: Annotated<bool>,
270
271    /// Data of the trace's root span.
272    #[metastructure(pii = "maybe", skip_serialization = "null")]
273    pub data: Annotated<SpanData>,
274
275    /// Links to other spans from the trace's root span.
276    #[metastructure(pii = "maybe", skip_serialization = "null")]
277    pub links: Annotated<Array<SpanLink>>,
278
279    /// Additional arbitrary fields for forwards compatibility.
280    #[metastructure(additional_properties, retain = true, pii = "maybe")]
281    pub other: Object<Value>,
282}
283
284impl super::DefaultContext for TraceContext {
285    fn default_key() -> &'static str {
286        "trace"
287    }
288
289    fn from_context(context: super::Context) -> Option<Self> {
290        match context {
291            super::Context::Trace(c) => Some(*c),
292            _ => None,
293        }
294    }
295
296    fn cast(context: &super::Context) -> Option<&Self> {
297        match context {
298            super::Context::Trace(c) => Some(c),
299            _ => None,
300        }
301    }
302
303    fn cast_mut(context: &mut super::Context) -> Option<&mut Self> {
304        match context {
305            super::Context::Trace(c) => Some(c),
306            _ => None,
307        }
308    }
309
310    fn into_context(self) -> super::Context {
311        super::Context::Trace(Box::new(self))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::protocol::{Context, Route};
319
320    #[test]
321    fn test_trace_id_as_u128() {
322        // Test valid hex string
323        let trace_id: TraceId = "4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap();
324        assert_eq!(trace_id.as_u128(), 0x4c79f60c11214eb38604f4ae0781bfb2);
325
326        // Test empty string (should return 0)
327        let empty_trace_id: Result<TraceId, Error> = "".parse();
328        assert!(empty_trace_id.is_err());
329
330        // Test string with invalid length (should return 0)
331        let short_trace_id: Result<TraceId, Error> = "4c79f60c11214eb38604f4ae0781bfb".parse(); // 31 chars
332        assert!(short_trace_id.is_err());
333
334        let long_trace_id: Result<TraceId, Error> = "4c79f60c11214eb38604f4ae0781bfb2a".parse(); // 33 chars
335        assert!(long_trace_id.is_err());
336
337        // Test string with invalid hex characters (should return 0)
338        let invalid_trace_id: Result<TraceId, Error> = "4c79f60c11214eb38604f4ae0781bfbg".parse(); // 'g' is not a hex char
339        assert!(invalid_trace_id.is_err());
340    }
341
342    #[test]
343    fn test_trace_context_roundtrip() {
344        let json = r#"{
345  "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
346  "span_id": "fa90fdead5f74052",
347  "parent_span_id": "fa90fdead5f74053",
348  "op": "http",
349  "status": "ok",
350  "exclusive_time": 0.0,
351  "client_sample_rate": 0.5,
352  "origin": "auto.http",
353  "data": {
354    "route": {
355      "name": "/users",
356      "params": {
357        "tok": "test"
358      },
359      "custom_field": "something"
360    },
361    "custom_field_empty": ""
362  },
363  "links": [
364    {
365      "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
366      "span_id": "ea90fdead5f74052",
367      "sampled": true,
368      "attributes": {
369        "sentry.link.type": "previous_trace"
370      }
371    }
372  ],
373  "other": "value",
374  "type": "trace"
375}"#;
376        let context = Annotated::new(Context::Trace(Box::new(TraceContext {
377            trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
378            span_id: Annotated::new("fa90fdead5f74052".parse().unwrap()),
379            parent_span_id: Annotated::new("fa90fdead5f74053".parse().unwrap()),
380            op: Annotated::new("http".into()),
381            status: Annotated::new(SpanStatus::Ok),
382            exclusive_time: Annotated::new(0.0),
383            client_sample_rate: Annotated::new(0.5),
384            origin: Annotated::new("auto.http".to_owned()),
385            data: Annotated::new(SpanData {
386                route: Annotated::new(Route {
387                    name: Annotated::new("/users".into()),
388                    params: Annotated::new({
389                        let mut map = Object::new();
390                        map.insert(
391                            "tok".to_string(),
392                            Annotated::new(Value::String("test".into())),
393                        );
394                        map
395                    }),
396                    other: Object::from([(
397                        "custom_field".into(),
398                        Annotated::new(Value::String("something".into())),
399                    )]),
400                }),
401                other: Object::from([(
402                    "custom_field_empty".into(),
403                    Annotated::new(Value::String("".into())),
404                )]),
405                ..Default::default()
406            }),
407            links: Annotated::new(Array::from(vec![Annotated::new(SpanLink {
408                trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
409                span_id: Annotated::new("ea90fdead5f74052".parse().unwrap()),
410                sampled: Annotated::new(true),
411                attributes: Annotated::new({
412                    let mut map: std::collections::BTreeMap<String, Annotated<Value>> =
413                        Object::new();
414                    map.insert(
415                        "sentry.link.type".into(),
416                        Annotated::new(Value::String("previous_trace".into())),
417                    );
418                    map
419                }),
420                ..Default::default()
421            })])),
422            other: {
423                let mut map = Object::new();
424                map.insert(
425                    "other".to_string(),
426                    Annotated::new(Value::String("value".to_string())),
427                );
428                map
429            },
430            sampled: Annotated::empty(),
431        })));
432
433        assert_eq!(context, Annotated::from_json(json).unwrap());
434        assert_eq!(json, context.to_json_pretty().unwrap());
435    }
436
437    #[test]
438    fn test_trace_context_normalization() {
439        let json = r#"{
440  "trace_id": "4C79F60C11214EB38604F4AE0781BFB2",
441  "span_id": "FA90FDEAD5F74052",
442  "type": "trace"
443}"#;
444        let context = Annotated::new(Context::Trace(Box::new(TraceContext {
445            trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
446            span_id: Annotated::new("fa90fdead5f74052".parse().unwrap()),
447            ..Default::default()
448        })));
449
450        assert_eq!(context, Annotated::from_json(json).unwrap());
451    }
452
453    #[test]
454    fn test_trace_id_formatting() {
455        let test_cases = [
456            // Test case 1: Formatting with hyphens in input
457            (
458                r#"{
459  "trace_id": "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
460  "type": "trace"
461}"#,
462                "b1e2a9dc-9b8e-4cd0-af0e-80e6b83b56e6",
463                true,
464            ),
465            // Test case 2: Parsing with hyphens in JSON
466            (
467                r#"{
468  "trace_id": "b1e2a9dc-9b8e-4cd0-af0e-80e6b83b56e6",
469  "type": "trace"
470}"#,
471                "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
472                false,
473            ),
474            // Test case 3: Uppercase in input
475            (
476                r#"{
477  "trace_id": "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
478  "type": "trace"
479}"#,
480                "B1E2A9DC9B8E4CD0AF0E80E6B83B56E6",
481                true,
482            ),
483            // Test case 4: Uppercase in JSON
484            (
485                r#"{
486  "trace_id": "B1E2A9DC9B8E4CD0AF0E80E6B83B56E6",
487  "type": "trace"
488}"#,
489                "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
490                false,
491            ),
492        ];
493
494        for (json, trace_id_str, is_to_json) in test_cases {
495            let context = Annotated::new(Context::Trace(Box::new(TraceContext {
496                trace_id: Annotated::new(trace_id_str.parse().unwrap()),
497                ..Default::default()
498            })));
499
500            if is_to_json {
501                assert_eq!(json, context.to_json_pretty().unwrap());
502            } else {
503                assert_eq!(context, Annotated::from_json(json).unwrap());
504            }
505        }
506    }
507
508    #[test]
509    fn test_trace_context_with_routes() {
510        let json = r#"{
511  "trace_id": "4C79F60C11214EB38604F4AE0781BFB2",
512  "span_id": "FA90FDEAD5F74052",
513  "type": "trace",
514  "data": {
515    "route": "HomeRoute"
516  }
517}"#;
518        let context = Annotated::new(Context::Trace(Box::new(TraceContext {
519            trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
520            span_id: Annotated::new("fa90fdead5f74052".parse().unwrap()),
521            data: Annotated::new(SpanData {
522                route: Annotated::new(Route {
523                    name: Annotated::new("HomeRoute".into()),
524                    ..Default::default()
525                }),
526                ..Default::default()
527            }),
528            ..Default::default()
529        })));
530
531        assert_eq!(context, Annotated::from_json(json).unwrap());
532    }
533}