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