Skip to main content

sentry_core/performance/
headers.rs

1//! Module containing utilities for interacting with Sentry tracing headers.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter, Result as FmtResult};
5
6#[cfg(feature = "client")]
7use sentry_types::protocol::v7::OrganizationId;
8
9use crate::protocol::{SpanId, TraceId};
10
11/// A key-value header pair.
12type Header<'h> = (&'h str, &'h str);
13
14/// The baggage key for the Sentry org ID.
15#[cfg(feature = "client")]
16const SENTRY_ORG_ID: &str = "sentry-org_id";
17
18/// The Sentry Trace header
19const SENTRY_TRACE: &str = "sentry-trace";
20
21/// The Baggage header
22const BAGGAGE: &str = "baggage";
23
24/// The [trace propagation] context.
25///
26/// Contains the information necessary for propagating Sentry traces and continuing traces from
27/// incoming requests.
28///
29/// The data stored in this struct can be parsed from and transmitted as `sentry-trace` and Sentry
30/// baggage headers.
31///
32/// Note that the Rust SDK only partially supports trace propagation, certain features such as
33/// [dynamic sampling] may be missing or incomplete.
34///
35/// [trace propagation]: https://develop.sentry.dev/sdk/foundations/trace-propagation/
36/// [dynamic sampling]: https://develop.sentry.dev/sdk/foundations/trace-propagation/dynamic-sampling-context/
37#[derive(Debug, PartialEq, Clone, Default)]
38pub struct TracePropagationContext {
39    pub(crate) trace_id: TraceId,
40    pub(crate) span_id: SpanId,
41    pub(super) sampled: Option<bool>,
42    #[cfg(feature = "client")]
43    pub(super) org_id: Option<OrganizationId>,
44}
45
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48/// Error type returned by [`TracePropagationContext::try_from_headers`].
49pub enum HeaderParseError {
50    /// The `sentry-trace` header was missing.
51    Missing,
52    /// There was a `sentry-trace` header, but it was invalid.
53    Invalid,
54}
55
56/// A container for `sentry-trace` data.
57#[deprecated = "Please use `TracePropagationContext` instead"]
58#[derive(Debug, PartialEq, Clone, Copy, Default)]
59pub struct SentryTrace {
60    trace_id: TraceId,
61    span_id: SpanId,
62    sampled: Option<bool>,
63}
64
65impl TracePropagationContext {
66    /// Creates a new [`TracePropagationContext`] from the provided parameters
67    pub fn new(trace_id: TraceId, span_id: SpanId) -> Self {
68        TracePropagationContext {
69            trace_id,
70            span_id,
71            sampled: None,
72            #[cfg(feature = "client")]
73            org_id: None,
74        }
75    }
76
77    /// Set the sampling decision on `self`.
78    pub fn with_sampled(self, sampled: bool) -> Self {
79        let sampled = Some(sampled);
80        Self { sampled, ..self }
81    }
82
83    /// Computes the `sentry-trace` header for this [`TracePropagationContext`].
84    pub fn sentry_trace_header(&self) -> String {
85        let Self {
86            trace_id,
87            span_id,
88            sampled,
89            #[cfg(feature = "client")]
90                org_id: _,
91        } = self;
92
93        let sampled_suffix = sampled
94            .map(|sampled| format!("-{}", if sampled { "1" } else { "0" }))
95            .unwrap_or_default();
96
97        format!("{trace_id}-{span_id}{sampled_suffix}")
98    }
99
100    /// Attempt to parse a list of Sentry headers into [`TracePropagationContext`].
101    ///
102    /// The parsing will fail if there is no valid `sentry-trace` header.
103    pub fn try_from_headers<'a, I>(headers: I) -> Result<Self, HeaderParseError>
104    where
105        I: IntoIterator<Item = Header<'a>>,
106    {
107        let mut context_result = Err(HeaderParseError::Missing);
108        #[cfg(feature = "client")]
109        let mut baggage = SentryBaggage::default();
110
111        for (header, value) in headers {
112            if header.eq_ignore_ascii_case(SENTRY_TRACE) {
113                // Parse the header, falling back to the previous header value if Ok (headers not
114                // guaranteed unique), only falling back to invalid error if there's no prev value.
115                context_result = TracePropagationContext::from_sentry_trace(value)
116                    .map_or(context_result, Ok)
117                    .map_err(|_| HeaderParseError::Invalid);
118            } else if header.eq_ignore_ascii_case(BAGGAGE) {
119                #[cfg(feature = "client")]
120                baggage.update_from_header(value);
121            }
122        }
123
124        let context = context_result?;
125
126        #[cfg(feature = "client")]
127        let SentryBaggage { org_id } = baggage;
128        Ok(TracePropagationContext {
129            #[cfg(feature = "client")]
130            org_id,
131            ..context
132        })
133    }
134
135    /// Set the `sampled` field, accepting `Option` values.
136    pub(crate) fn with_maybe_sampled(self, sampled: Option<bool>) -> Self {
137        Self { sampled, ..self }
138    }
139
140    /// Attempts to construct a [`TracePropagationContext`] from the given Sentry trace header.
141    ///
142    /// Returns [`None`] if the header cannot be parsed.
143    fn from_sentry_trace(header: &str) -> Option<Self> {
144        let header = header.trim();
145        let mut parts = header.splitn(3, '-');
146
147        let trace_id = parts.next()?.parse().ok()?;
148        let span_id = parts.next()?.parse().ok()?;
149        let sampled = parts.next().and_then(|sampled| match sampled {
150            "1" => Some(true),
151            "0" => Some(false),
152            _ => None,
153        });
154
155        Some(Self {
156            trace_id,
157            span_id,
158            sampled,
159            #[cfg(feature = "client")]
160            org_id: None,
161        })
162    }
163}
164
165/// Extracts distributed tracing metadata from headers (or, generally, key-value pairs),
166/// considering the values for `sentry-trace`.
167#[deprecated = "use TracePropagationContext::try_from_headers instead"]
168#[expect(deprecated, reason = "backwards-compatible function")]
169pub fn parse_sentry_trace_header<'a, I>(headers: I) -> Option<SentryTrace>
170where
171    I: IntoIterator<Item = Header<'a>>,
172{
173    let TracePropagationContext {
174        trace_id,
175        span_id,
176        sampled,
177        #[cfg(feature = "client")]
178            org_id: _,
179    } = TracePropagationContext::try_from_headers(headers).ok()?;
180
181    Some(SentryTrace {
182        trace_id,
183        span_id,
184        sampled,
185    })
186}
187
188impl Display for HeaderParseError {
189    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
190        let msg = match self {
191            HeaderParseError::Missing => "missing",
192            HeaderParseError::Invalid => "invalid",
193        };
194
195        write!(f, "{msg} {SENTRY_TRACE} header")
196    }
197}
198
199impl Error for HeaderParseError {}
200
201#[expect(deprecated, reason = "backwards-compatible impl")]
202impl SentryTrace {
203    /// Creates a new [`SentryTrace`] from the provided parameters
204    pub fn new(trace_id: TraceId, span_id: SpanId, sampled: Option<bool>) -> Self {
205        Self {
206            trace_id,
207            span_id,
208            sampled,
209        }
210    }
211}
212
213#[expect(deprecated, reason = "backwards-compatible impl")]
214impl From<SentryTrace> for TracePropagationContext {
215    fn from(trace: SentryTrace) -> Self {
216        Self {
217            trace_id: trace.trace_id,
218            span_id: trace.span_id,
219            sampled: trace.sampled,
220            #[cfg(feature = "client")]
221            org_id: None,
222        }
223    }
224}
225
226#[expect(deprecated, reason = "backwards-compatible impl")]
227impl std::fmt::Display for SentryTrace {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        write!(f, "{}-{}", self.trace_id, self.span_id)?;
230        if let Some(sampled) = self.sampled {
231            write!(f, "-{}", if sampled { '1' } else { '0' })?;
232        }
233        Ok(())
234    }
235}
236
237/// A struct containing known Sentry baggage values.
238///
239/// For now, this only includes the `org_id`, but we can add more values as we support them.
240#[cfg(feature = "client")]
241#[derive(Debug, Default)]
242struct SentryBaggage {
243    org_id: Option<OrganizationId>,
244}
245
246#[cfg(feature = "client")]
247impl SentryBaggage {
248    /// Update `self` with the known Sentry baggage values in the provided [baggage header].
249    ///
250    /// The header is parsed according to the W3C baggage format: entries are separated by
251    /// commas, each entry is a key-value pair separated by `=`, and optional properties after
252    /// a semicolon are ignored.
253    ///
254    /// [baggage header]: https://www.w3.org/TR/baggage/
255    fn update_from_header(&mut self, value: &str) {
256        value
257            .split(',')
258            .flat_map(|s| s.split_once('='))
259            // Discard optional values after semicolon.
260            .map(|(key, value)| (key, value.split_once(';').map_or(value, |(v, _)| v)))
261            .map(|(key, value)| (key.trim(), value.trim()))
262            .for_each(|(key, value)| self.update_from_value(key, value))
263    }
264
265    /// Update `self` with a key-value pair from the baggage header.
266    ///
267    /// The value is only updated if it is valid, otherwise the old value is kept.
268    fn update_from_value(&mut self, key: &str, value: &str) {
269        if key == SENTRY_ORG_ID {
270            self.org_id = value.parse().ok().or(self.org_id);
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn parses_sentry_trace() {
281        let trace_id = "09e04486820349518ac7b5d2adbf6ba5".parse().unwrap();
282        let parent_trace_id = "9cf635fa5b870b3a".parse().unwrap();
283
284        let trace = TracePropagationContext::try_from_headers([(
285            "sentry-trace",
286            "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
287        )])
288        .expect("should parse successfully");
289        assert_eq!(
290            trace,
291            TracePropagationContext {
292                trace_id,
293                span_id: parent_trace_id,
294                sampled: Some(false),
295                #[cfg(feature = "client")]
296                org_id: None,
297            }
298        );
299
300        let trace = TracePropagationContext::new(Default::default(), Default::default());
301        let parsed = TracePropagationContext::try_from_headers([(
302            "sentry-trace",
303            trace.sentry_trace_header().as_str(),
304        )])
305        .expect("should parse successfully");
306        assert_eq!(parsed, trace);
307    }
308
309    #[cfg(feature = "client")]
310    #[test]
311    fn parses_baggage_org_id() {
312        let trace = TracePropagationContext::try_from_headers([
313            (
314                "sentry-trace",
315                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
316            ),
317            ("baggage", "sentry-org_id=123"),
318        ])
319        .expect("should parse successfully");
320
321        assert_eq!(trace.org_id, Some("123".parse().unwrap()));
322    }
323
324    #[cfg(feature = "client")]
325    #[test]
326    fn parses_baggage_org_id_with_unrelated_fields() {
327        let trace = TracePropagationContext::try_from_headers([
328            (
329                "sentry-trace",
330                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
331            ),
332            (
333                "baggage",
334                "other=value, sentry-org_id=123, another=value;property",
335            ),
336        ])
337        .expect("should parse successfully");
338
339        assert_eq!(trace.org_id, Some("123".parse().unwrap()));
340    }
341
342    #[cfg(feature = "client")]
343    #[test]
344    fn accepts_mixed_case_baggage_header_name() {
345        let trace = TracePropagationContext::try_from_headers([
346            (
347                "sentry-trace",
348                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
349            ),
350            ("BagGaGe", "sentry-org_id=123"),
351        ])
352        .expect("should parse successfully");
353
354        assert_eq!(trace.org_id, Some("123".parse().unwrap()));
355    }
356
357    #[cfg(feature = "client")]
358    #[test]
359    fn treats_malformed_baggage_org_id_as_absent() {
360        let trace = TracePropagationContext::try_from_headers([
361            (
362                "sentry-trace",
363                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
364            ),
365            ("baggage", "sentry-org_id=not-an-org-id"),
366        ])
367        .expect("should parse successfully");
368
369        assert_eq!(trace.org_id, None);
370    }
371}