Skip to main content

relay_event_schema/protocol/contexts/
trace.rs

1use relay_protocol::{
2    Annotated, Array, Empty, Error, FromValue, HexId, IntoValue, Meta, Object, Remark, RemarkType,
3    SkipSerialization, Val, 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, PartialEq, Empty, ProcessValue)]
30pub struct TraceId(Uuid);
31
32impl TraceId {
33    /// Creates a new, random, trace id.
34    pub fn random() -> Self {
35        Self(Uuid::new_v4())
36    }
37
38    /// Parses a [`TraceId`] from a value that can be converted into one.
39    ///
40    /// If parsing fails, uses a [`Self::random`] id instead and adds a remark.
41    pub fn try_from_or_random<T>(value: T) -> Annotated<Self>
42    where
43        T: TryInto<Self> + AsRef<[u8]> + Copy,
44    {
45        value.try_into().map(Annotated::new).unwrap_or_else(|_| {
46            let mut meta = Meta::default();
47            let rule_id = match value.as_ref().is_empty() {
48                true => "trace_id.missing",
49                false => "trace_id.invalid",
50            };
51            meta.add_remark(Remark::new(RemarkType::Substituted, rule_id));
52            Annotated(Some(TraceId::random()), meta)
53        })
54    }
55
56    /// Parses a [`TraceId`] from a slice, if it fails uses a [`Self::random`] id instead.
57    pub fn try_from_slice_or_random(value: &[u8]) -> Annotated<Self> {
58        Self::try_from_or_random(value)
59    }
60
61    /// Parses a [`TraceId`] from a string, if it fails uses a [`Self::random`] id instead.
62    pub fn try_from_str_or_random(value: &str) -> Annotated<Self> {
63        Self::try_from_or_random(value)
64    }
65}
66
67relay_common::impl_str_serde!(TraceId, "a trace identifier");
68
69impl FromStr for TraceId {
70    type Err = Error;
71
72    fn from_str(s: &str) -> Result<Self, Self::Err> {
73        Uuid::parse_str(s)
74            .map(Into::into)
75            .map_err(|_| Error::invalid("the trace id is not valid"))
76    }
77}
78
79impl TryFrom<&str> for TraceId {
80    type Error = Error;
81
82    fn try_from(value: &str) -> Result<Self, Self::Error> {
83        value.parse()
84    }
85}
86
87impl TryFrom<&[u8]> for TraceId {
88    type Error = Error;
89
90    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
91        let uuid =
92            Uuid::from_slice(value).map_err(|_| Error::invalid("the trace id is not valid"))?;
93        Ok(Self(uuid))
94    }
95}
96
97impl fmt::Display for TraceId {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}", self.0.as_simple())
100    }
101}
102
103impl fmt::Debug for TraceId {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "TraceId(\"{}\")", self.0.as_simple())
106    }
107}
108
109impl From<Uuid> for TraceId {
110    fn from(uuid: Uuid) -> Self {
111        TraceId(uuid)
112    }
113}
114
115impl Deref for TraceId {
116    type Target = Uuid;
117
118    fn deref(&self) -> &Self::Target {
119        &self.0
120    }
121}
122
123impl FromValue for TraceId {
124    fn from_value(value: Annotated<Value>) -> Annotated<Self>
125    where
126        Self: Sized,
127    {
128        match value {
129            Annotated(Some(Value::String(value)), mut meta) => match value.parse() {
130                Ok(trace_id) => Annotated(Some(trace_id), meta),
131                Err(_) => {
132                    meta.add_error(Error::invalid("not a valid trace id"));
133                    meta.set_original_value(Some(value));
134                    Annotated(None, meta)
135                }
136            },
137            Annotated(None, meta) => Annotated(None, meta),
138            Annotated(Some(value), mut meta) => {
139                meta.add_error(Error::expected("trace id"));
140                meta.set_original_value(Some(value));
141                Annotated(None, meta)
142            }
143        }
144    }
145}
146
147impl IntoValue for TraceId {
148    fn into_value(self) -> Value
149    where
150        Self: Sized,
151    {
152        Value::String(self.to_string())
153    }
154
155    fn serialize_payload<S>(&self, s: S, _behavior: SkipSerialization) -> Result<S::Ok, S::Error>
156    where
157        Self: Sized,
158        S: Serializer,
159    {
160        s.collect_str(self)
161    }
162}
163
164/// A 16-character hex string as described in the W3C trace context spec, stored
165/// internally as an array of 8 bytes.
166#[derive(Clone, Copy, Default, Eq, Hash, PartialEq, Ord, PartialOrd)]
167pub struct SpanId([u8; 8]);
168
169relay_common::impl_str_serde!(SpanId, "a span identifier");
170
171impl SpanId {
172    pub fn random() -> Self {
173        let value: u64 = rand::random_range(1..=u64::MAX);
174        Self(value.to_ne_bytes())
175    }
176}
177
178impl FromStr for SpanId {
179    type Err = Error;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        match u64::from_str_radix(s, 16) {
183            Ok(id) if s.len() == 16 && id > 0 => Ok(Self(id.to_be_bytes())),
184            _ => Err(Error::invalid("not a valid span id")),
185        }
186    }
187}
188
189impl TryFrom<&[u8]> for SpanId {
190    type Error = Error;
191
192    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
193        match <[u8; 8]>::try_from(value) {
194            Ok(bytes) if !bytes.iter().all(|&x| x == 0) => Ok(Self(bytes)),
195            _ => Err(Error::invalid("not a valid span id")),
196        }
197    }
198}
199
200impl fmt::Debug for SpanId {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "SpanId(\"")?;
203        for b in self.0 {
204            write!(f, "{b:02x}")?;
205        }
206        write!(f, "\")")
207    }
208}
209
210impl fmt::Display for SpanId {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        for b in self.0 {
213            write!(f, "{b:02x}")?;
214        }
215        Ok(())
216    }
217}
218
219impl FromValue for SpanId {
220    fn from_value(value: Annotated<Value>) -> Annotated<Self> {
221        match value {
222            Annotated(Some(Value::String(value)), mut meta) => match value.parse() {
223                Ok(span_id) => Annotated::new(span_id),
224                Err(e) => {
225                    meta.add_error(e);
226                    meta.set_original_value(Some(value));
227                    Annotated(None, meta)
228                }
229            },
230            Annotated(None, meta) => Annotated(None, meta),
231            Annotated(Some(value), mut meta) => {
232                meta.add_error(Error::expected("span id"));
233                meta.set_original_value(Some(value));
234                Annotated(None, meta)
235            }
236        }
237    }
238}
239
240impl Empty for SpanId {
241    fn is_empty(&self) -> bool {
242        false
243    }
244}
245
246impl IntoValue for SpanId {
247    fn into_value(self) -> Value
248    where
249        Self: Sized,
250    {
251        Value::String(self.to_string())
252    }
253
254    fn serialize_payload<S>(&self, s: S, _behavior: SkipSerialization) -> Result<S::Ok, S::Error>
255    where
256        Self: Sized,
257        S: serde::Serializer,
258    {
259        s.collect_str(self)
260    }
261}
262
263impl ProcessValue for SpanId {}
264
265impl std::ops::Deref for SpanId {
266    type Target = [u8];
267
268    fn deref(&self) -> &Self::Target {
269        &self.0
270    }
271}
272
273impl<'a> From<&'a SpanId> for Val<'a> {
274    fn from(value: &'a SpanId) -> Self {
275        Val::HexId(HexId(&value.0))
276    }
277}
278
279/// Trace context
280#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
281#[metastructure(process_func = "process_trace_context")]
282pub struct TraceContext {
283    /// The trace ID.
284    #[metastructure(required = true)]
285    pub trace_id: Annotated<TraceId>,
286
287    /// The ID of the span.
288    #[metastructure(required = true)]
289    pub span_id: Annotated<SpanId>,
290
291    /// The ID of the span enclosing this span.
292    pub parent_span_id: Annotated<SpanId>,
293
294    /// Span type (see `OperationType` docs).
295    #[metastructure(max_chars = 128)]
296    pub op: Annotated<OperationType>,
297
298    /// Whether the trace failed or succeeded. Currently only used to indicate status of individual
299    /// transactions.
300    pub status: Annotated<SpanStatus>,
301
302    /// The amount of time in milliseconds spent in this transaction span,
303    /// excluding its immediate child spans.
304    pub exclusive_time: Annotated<f64>,
305
306    /// The client-side sample rate as reported in the envelope's `trace.sample_rate` header.
307    ///
308    /// The server takes this field from envelope headers and writes it back into the event. Clients
309    /// should not ever send this value.
310    pub client_sample_rate: Annotated<f64>,
311
312    /// The origin of the trace indicates what created the trace (see [OriginType] docs).
313    #[metastructure(max_chars = 128, allow_chars = "a-zA-Z0-9_.")]
314    pub origin: Annotated<OriginType>,
315
316    /// Track whether the trace connected to this event has been sampled entirely.
317    ///
318    /// This flag only applies to events with [`Error`] type that have an associated dynamic sampling context.
319    pub sampled: Annotated<bool>,
320
321    /// Data of the trace's root span.
322    #[metastructure(pii = "maybe", skip_serialization = "null")]
323    pub data: Annotated<SpanData>,
324
325    /// Links to other spans from the trace's root span.
326    #[metastructure(pii = "maybe", skip_serialization = "null")]
327    pub links: Annotated<Array<SpanLink>>,
328
329    /// Additional arbitrary fields for forwards compatibility.
330    #[metastructure(additional_properties, retain = true, pii = "maybe")]
331    pub other: Object<Value>,
332}
333
334impl TraceContext {
335    /// Generates a random [`SpanId`] and takes `[TraceId]` from the event's UUID.
336    /// Leaves all other fields blank.
337    pub fn random(event_id: Uuid) -> Self {
338        let mut trace_meta = Meta::default();
339        trace_meta.add_remark(Remark::new(RemarkType::Substituted, "trace_id.missing"));
340
341        let mut span_meta = Meta::default();
342        span_meta.add_remark(Remark::new(RemarkType::Substituted, "span_id.missing"));
343        TraceContext {
344            trace_id: Annotated(Some(TraceId::from(event_id)), trace_meta),
345            span_id: Annotated(Some(SpanId::random()), span_meta),
346            ..Default::default()
347        }
348    }
349}
350
351impl super::DefaultContext for TraceContext {
352    fn default_key() -> &'static str {
353        "trace"
354    }
355
356    fn from_context(context: super::Context) -> Option<Self> {
357        match context {
358            super::Context::Trace(c) => Some(*c),
359            _ => None,
360        }
361    }
362
363    fn cast(context: &super::Context) -> Option<&Self> {
364        match context {
365            super::Context::Trace(c) => Some(c),
366            _ => None,
367        }
368    }
369
370    fn cast_mut(context: &mut super::Context) -> Option<&mut Self> {
371        match context {
372            super::Context::Trace(c) => Some(c),
373            _ => None,
374        }
375    }
376
377    fn into_context(self) -> super::Context {
378        super::Context::Trace(Box::new(self))
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::protocol::{Context, Route};
386
387    #[test]
388    fn test_trace_id_as_u128() {
389        // Test valid hex string
390        let trace_id: TraceId = "4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap();
391        assert_eq!(trace_id.as_u128(), 0x4c79f60c11214eb38604f4ae0781bfb2);
392
393        // Test empty string (should return 0)
394        let empty_trace_id: Result<TraceId, Error> = "".parse();
395        assert!(empty_trace_id.is_err());
396
397        // Test string with invalid length (should return 0)
398        let short_trace_id: Result<TraceId, Error> = "4c79f60c11214eb38604f4ae0781bfb".parse(); // 31 chars
399        assert!(short_trace_id.is_err());
400
401        let long_trace_id: Result<TraceId, Error> = "4c79f60c11214eb38604f4ae0781bfb2a".parse(); // 33 chars
402        assert!(long_trace_id.is_err());
403
404        // Test string with invalid hex characters (should return 0)
405        let invalid_trace_id: Result<TraceId, Error> = "4c79f60c11214eb38604f4ae0781bfbg".parse(); // 'g' is not a hex char
406        assert!(invalid_trace_id.is_err());
407    }
408
409    #[test]
410    fn test_trace_context_roundtrip() {
411        let json = r#"{
412  "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
413  "span_id": "fa90fdead5f74052",
414  "parent_span_id": "fa90fdead5f74053",
415  "op": "http",
416  "status": "ok",
417  "exclusive_time": 0.0,
418  "client_sample_rate": 0.5,
419  "origin": "auto.http",
420  "data": {
421    "route": {
422      "name": "/users",
423      "params": {
424        "tok": "test"
425      },
426      "custom_field": "something"
427    },
428    "custom_field_empty": ""
429  },
430  "links": [
431    {
432      "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
433      "span_id": "ea90fdead5f74052",
434      "sampled": true,
435      "attributes": {
436        "sentry.link.type": "previous_trace"
437      }
438    }
439  ],
440  "other": "value",
441  "type": "trace"
442}"#;
443        let context = Annotated::new(Context::Trace(Box::new(TraceContext {
444            trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
445            span_id: Annotated::new("fa90fdead5f74052".parse().unwrap()),
446            parent_span_id: Annotated::new("fa90fdead5f74053".parse().unwrap()),
447            op: Annotated::new("http".into()),
448            status: Annotated::new(SpanStatus::Ok),
449            exclusive_time: Annotated::new(0.0),
450            client_sample_rate: Annotated::new(0.5),
451            origin: Annotated::new("auto.http".to_owned()),
452            data: Annotated::new(SpanData {
453                route: Annotated::new(Route {
454                    name: Annotated::new("/users".into()),
455                    params: Annotated::new({
456                        let mut map = Object::new();
457                        map.insert(
458                            "tok".to_owned(),
459                            Annotated::new(Value::String("test".into())),
460                        );
461                        map
462                    }),
463                    other: Object::from([(
464                        "custom_field".into(),
465                        Annotated::new(Value::String("something".into())),
466                    )]),
467                }),
468                other: Object::from([(
469                    "custom_field_empty".into(),
470                    Annotated::new(Value::String("".into())),
471                )]),
472                ..Default::default()
473            }),
474            links: Annotated::new(Array::from(vec![Annotated::new(SpanLink {
475                trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
476                span_id: Annotated::new("ea90fdead5f74052".parse().unwrap()),
477                sampled: Annotated::new(true),
478                attributes: Annotated::new({
479                    let mut map: std::collections::BTreeMap<String, Annotated<Value>> =
480                        Object::new();
481                    map.insert(
482                        "sentry.link.type".into(),
483                        Annotated::new(Value::String("previous_trace".into())),
484                    );
485                    map
486                }),
487                ..Default::default()
488            })])),
489            other: {
490                let mut map = Object::new();
491                map.insert(
492                    "other".to_owned(),
493                    Annotated::new(Value::String("value".to_owned())),
494                );
495                map
496            },
497            sampled: Annotated::empty(),
498        })));
499
500        assert_eq!(context, Annotated::from_json(json).unwrap());
501        assert_eq!(json, context.to_json_pretty().unwrap());
502    }
503
504    #[test]
505    fn test_trace_context_normalization() {
506        let json = r#"{
507  "trace_id": "4C79F60C11214EB38604F4AE0781BFB2",
508  "span_id": "FA90FDEAD5F74052",
509  "type": "trace"
510}"#;
511        let context = Annotated::new(Context::Trace(Box::new(TraceContext {
512            trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
513            span_id: Annotated::new("fa90fdead5f74052".parse().unwrap()),
514            ..Default::default()
515        })));
516
517        assert_eq!(context, Annotated::from_json(json).unwrap());
518    }
519
520    #[test]
521    fn test_trace_id_formatting() {
522        let test_cases = [
523            // Test case 1: Formatting with hyphens in input
524            (
525                r#"{
526  "trace_id": "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
527  "type": "trace"
528}"#,
529                "b1e2a9dc-9b8e-4cd0-af0e-80e6b83b56e6",
530                true,
531            ),
532            // Test case 2: Parsing with hyphens in JSON
533            (
534                r#"{
535  "trace_id": "b1e2a9dc-9b8e-4cd0-af0e-80e6b83b56e6",
536  "type": "trace"
537}"#,
538                "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
539                false,
540            ),
541            // Test case 3: Uppercase in input
542            (
543                r#"{
544  "trace_id": "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
545  "type": "trace"
546}"#,
547                "B1E2A9DC9B8E4CD0AF0E80E6B83B56E6",
548                true,
549            ),
550            // Test case 4: Uppercase in JSON
551            (
552                r#"{
553  "trace_id": "B1E2A9DC9B8E4CD0AF0E80E6B83B56E6",
554  "type": "trace"
555}"#,
556                "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
557                false,
558            ),
559        ];
560
561        for (json, trace_id_str, is_to_json) in test_cases {
562            let context = Annotated::new(Context::Trace(Box::new(TraceContext {
563                trace_id: Annotated::new(trace_id_str.parse().unwrap()),
564                ..Default::default()
565            })));
566
567            if is_to_json {
568                assert_eq!(json, context.to_json_pretty().unwrap());
569            } else {
570                assert_eq!(context, Annotated::from_json(json).unwrap());
571            }
572        }
573    }
574
575    #[test]
576    fn test_trace_context_with_routes() {
577        let json = r#"{
578  "trace_id": "4C79F60C11214EB38604F4AE0781BFB2",
579  "span_id": "FA90FDEAD5F74052",
580  "type": "trace",
581  "data": {
582    "route": "HomeRoute"
583  }
584}"#;
585        let context = Annotated::new(Context::Trace(Box::new(TraceContext {
586            trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
587            span_id: Annotated::new("fa90fdead5f74052".parse().unwrap()),
588            data: Annotated::new(SpanData {
589                route: Annotated::new(Route {
590                    name: Annotated::new("HomeRoute".into()),
591                    ..Default::default()
592                }),
593                ..Default::default()
594            }),
595            ..Default::default()
596        })));
597
598        assert_eq!(context, Annotated::from_json(json).unwrap());
599    }
600
601    #[test]
602    fn test_try_from_or_random() {
603        // Test valid string
604        let valid_str = "4c79f60c11214eb38604f4ae0781bfb2";
605        let annotated = TraceId::try_from_str_or_random(valid_str);
606        assert_eq!(
607            annotated.value().unwrap().as_u128(),
608            0x4c79f60c11214eb38604f4ae0781bfb2
609        );
610        assert!(annotated.meta().is_empty());
611
612        // Test invalid string (should return random + remark)
613        let invalid_str = "invalid";
614        let annotated = TraceId::try_from_str_or_random(invalid_str);
615        assert!(annotated.value().is_some()); // Random trace ID
616        assert_ne!(annotated.value().unwrap().as_u128(), 0);
617        assert_eq!(annotated.meta().iter_remarks().count(), 1);
618        let remark = annotated.meta().iter_remarks().next().unwrap();
619        assert_eq!(remark.rule_id(), "trace_id.invalid");
620
621        // Test empty string (should return random + remark)
622        let empty_str = "";
623        let annotated = TraceId::try_from_str_or_random(empty_str);
624        assert!(annotated.value().is_some());
625        let remark = annotated.meta().iter_remarks().next().unwrap();
626        assert_eq!(remark.rule_id(), "trace_id.missing");
627
628        // Test valid slice
629        let valid_bytes = b"\x4c\x79\xf6\x0c\x11\x21\x4e\xb3\x86\x04\xf4\xae\x07\x81\xbf\xb2";
630        let annotated = TraceId::try_from_slice_or_random(valid_bytes.as_slice());
631        assert_eq!(
632            annotated.value().unwrap().as_u128(),
633            0x4c79f60c11214eb38604f4ae0781bfb2
634        );
635
636        // Test invalid slice length
637        let invalid_bytes = b"\x00";
638        let annotated = TraceId::try_from_slice_or_random(invalid_bytes.as_slice());
639        let remark = annotated.meta().iter_remarks().next().unwrap();
640        assert_eq!(remark.rule_id(), "trace_id.invalid");
641    }
642
643    #[test]
644    fn test_random_trace_context() {
645        let rand_context = TraceContext::random(Uuid::new_v4());
646        assert!(rand_context.trace_id.value().is_some());
647        assert_eq!(
648            rand_context
649                .trace_id
650                .meta()
651                .iter_remarks()
652                .next()
653                .unwrap()
654                .rule_id(),
655            "trace_id.missing"
656        );
657        assert!(rand_context.span_id.value().is_some());
658        assert_eq!(
659            rand_context
660                .span_id
661                .meta()
662                .iter_remarks()
663                .next()
664                .unwrap()
665                .rule_id(),
666            "span_id.missing"
667        );
668    }
669}