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