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    /// Attempts to construct a [`TracePropagationContext`] from the given Sentry trace header.
136    ///
137    /// Returns [`None`] if the header cannot be parsed.
138    fn from_sentry_trace(header: &str) -> Option<Self> {
139        let header = header.trim();
140        let mut parts = header.splitn(3, '-');
141
142        let trace_id = parts.next()?.parse().ok()?;
143        let span_id = parts.next()?.parse().ok()?;
144        let sampled = parts.next().and_then(|sampled| match sampled {
145            "1" => Some(true),
146            "0" => Some(false),
147            _ => None,
148        });
149
150        Some(Self {
151            trace_id,
152            span_id,
153            sampled,
154            #[cfg(feature = "client")]
155            org_id: None,
156        })
157    }
158}
159
160/// Extracts distributed tracing metadata from headers (or, generally, key-value pairs),
161/// considering the values for `sentry-trace`.
162#[deprecated = "use TracePropagationContext::try_from_headers instead"]
163#[expect(deprecated, reason = "backwards-compatible function")]
164pub fn parse_sentry_trace_header<'a, I>(headers: I) -> Option<SentryTrace>
165where
166    I: IntoIterator<Item = Header<'a>>,
167{
168    let TracePropagationContext {
169        trace_id,
170        span_id,
171        sampled,
172        #[cfg(feature = "client")]
173            org_id: _,
174    } = TracePropagationContext::try_from_headers(headers).ok()?;
175
176    Some(SentryTrace {
177        trace_id,
178        span_id,
179        sampled,
180    })
181}
182
183impl Display for HeaderParseError {
184    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
185        let msg = match self {
186            HeaderParseError::Missing => "missing",
187            HeaderParseError::Invalid => "invalid",
188        };
189
190        write!(f, "{msg} {SENTRY_TRACE} header")
191    }
192}
193
194impl Error for HeaderParseError {}
195
196#[expect(deprecated, reason = "backwards-compatible impl")]
197impl SentryTrace {
198    /// Creates a new [`SentryTrace`] from the provided parameters
199    pub fn new(trace_id: TraceId, span_id: SpanId, sampled: Option<bool>) -> Self {
200        Self {
201            trace_id,
202            span_id,
203            sampled,
204        }
205    }
206}
207
208#[expect(deprecated, reason = "backwards-compatible impl")]
209impl From<SentryTrace> for TracePropagationContext {
210    fn from(trace: SentryTrace) -> Self {
211        Self {
212            trace_id: trace.trace_id,
213            span_id: trace.span_id,
214            sampled: trace.sampled,
215            #[cfg(feature = "client")]
216            org_id: None,
217        }
218    }
219}
220
221#[expect(deprecated, reason = "backwards-compatible impl")]
222impl std::fmt::Display for SentryTrace {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        write!(f, "{}-{}", self.trace_id, self.span_id)?;
225        if let Some(sampled) = self.sampled {
226            write!(f, "-{}", if sampled { '1' } else { '0' })?;
227        }
228        Ok(())
229    }
230}
231
232/// A struct containing known Sentry baggage values.
233///
234/// For now, this only includes the `org_id`, but we can add more values as we support them.
235#[cfg(feature = "client")]
236#[derive(Debug, Default)]
237struct SentryBaggage {
238    org_id: Option<OrganizationId>,
239}
240
241#[cfg(feature = "client")]
242impl SentryBaggage {
243    /// Update `self` with the known Sentry baggage values in the provided [baggage header].
244    ///
245    /// The header is parsed according to the W3C baggage format: entries are separated by
246    /// commas, each entry is a key-value pair separated by `=`, and optional properties after
247    /// a semicolon are ignored.
248    ///
249    /// [baggage header]: https://www.w3.org/TR/baggage/
250    fn update_from_header(&mut self, value: &str) {
251        value
252            .split(',')
253            .flat_map(|s| s.split_once('='))
254            // Discard optional values after semicolon.
255            .map(|(key, value)| (key, value.split_once(';').map_or(value, |(v, _)| v)))
256            .map(|(key, value)| (key.trim(), value.trim()))
257            .for_each(|(key, value)| self.update_from_value(key, value))
258    }
259
260    /// Update `self` with a key-value pair from the baggage header.
261    ///
262    /// The value is only updated if it is valid, otherwise the old value is kept.
263    fn update_from_value(&mut self, key: &str, value: &str) {
264        if key == SENTRY_ORG_ID {
265            self.org_id = value.parse().ok().or(self.org_id);
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn parses_sentry_trace() {
276        let trace_id = "09e04486820349518ac7b5d2adbf6ba5".parse().unwrap();
277        let parent_trace_id = "9cf635fa5b870b3a".parse().unwrap();
278
279        let trace = TracePropagationContext::try_from_headers([(
280            "sentry-trace",
281            "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
282        )])
283        .expect("should parse successfully");
284        assert_eq!(
285            trace,
286            TracePropagationContext {
287                trace_id,
288                span_id: parent_trace_id,
289                sampled: Some(false),
290                #[cfg(feature = "client")]
291                org_id: None,
292            }
293        );
294
295        let trace = TracePropagationContext::new(Default::default(), Default::default());
296        let parsed = TracePropagationContext::try_from_headers([(
297            "sentry-trace",
298            trace.sentry_trace_header().as_str(),
299        )])
300        .expect("should parse successfully");
301        assert_eq!(parsed, trace);
302    }
303
304    #[cfg(feature = "client")]
305    #[test]
306    fn parses_baggage_org_id() {
307        let trace = TracePropagationContext::try_from_headers([
308            (
309                "sentry-trace",
310                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
311            ),
312            ("baggage", "sentry-org_id=123"),
313        ])
314        .expect("should parse successfully");
315
316        assert_eq!(trace.org_id, Some("123".parse().unwrap()));
317    }
318
319    #[cfg(feature = "client")]
320    #[test]
321    fn parses_baggage_org_id_with_unrelated_fields() {
322        let trace = TracePropagationContext::try_from_headers([
323            (
324                "sentry-trace",
325                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
326            ),
327            (
328                "baggage",
329                "other=value, sentry-org_id=123, another=value;property",
330            ),
331        ])
332        .expect("should parse successfully");
333
334        assert_eq!(trace.org_id, Some("123".parse().unwrap()));
335    }
336
337    #[cfg(feature = "client")]
338    #[test]
339    fn accepts_mixed_case_baggage_header_name() {
340        let trace = TracePropagationContext::try_from_headers([
341            (
342                "sentry-trace",
343                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
344            ),
345            ("BagGaGe", "sentry-org_id=123"),
346        ])
347        .expect("should parse successfully");
348
349        assert_eq!(trace.org_id, Some("123".parse().unwrap()));
350    }
351
352    #[cfg(feature = "client")]
353    #[test]
354    fn treats_malformed_baggage_org_id_as_absent() {
355        let trace = TracePropagationContext::try_from_headers([
356            (
357                "sentry-trace",
358                "09e04486820349518ac7b5d2adbf6ba5-9cf635fa5b870b3a-0",
359            ),
360            ("baggage", "sentry-org_id=not-an-org-id"),
361        ])
362        .expect("should parse successfully");
363
364        assert_eq!(trace.org_id, None);
365    }
366}