relay_event_schema/protocol/contexts/
trace.rs

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