Skip to main content

relay_sampling/
dsc.rs

1//! Contextual information stored on traces.
2//!
3//! [`DynamicSamplingContext`] (DSC) contains properties associated to traces that are shared
4//! between individual data submissions to Sentry. These properties are supposed to not change
5//! during the lifetime of the trace.
6//!
7//! The information in the DSC is used to compute a deterministic sampling decision without access
8//! to all individual data in the trace.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use relay_base_schema::project::{ProjectId, ProjectKey};
14use relay_event_schema::protocol::TraceId;
15use relay_protocol::{Getter, Val};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use uuid::Uuid;
19
20/// DynamicSamplingContext created by the first Sentry SDK in the call chain.
21///
22/// Because SDKs need to funnel this data through the baggage header, this needs to be
23/// representable as `HashMap<String, String>`, meaning no nested dictionaries/objects, arrays or
24/// other non-string values.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DynamicSamplingContext {
27    /// ID created by clients to represent the current call flow.
28    pub trace_id: TraceId,
29    /// The project key.
30    pub public_key: ProjectKey,
31    /// The sampling project id.
32    ///
33    /// Not part of the DSC protocol, but accessed and used together with the rest of the DSC.
34    /// Placed here for ergonomy and to avoid having to mutate the request context when the sampling
35    /// org is different from the sending org.
36    #[serde(skip)]
37    pub project_id: Option<ProjectId>,
38    /// The release.
39    #[serde(default)]
40    pub release: Option<String>,
41    /// The environment.
42    #[serde(default)]
43    pub environment: Option<String>,
44    /// In the transaction-based model, this is the name of the transaction extracted from the `transaction`
45    /// field in the starting transaction and set on transaction start, or via `scope.transaction`.
46    ///
47    /// In the spans-only model, this is the segment name for the segment that started the trace.
48    #[serde(default, alias = "segment_name")]
49    pub transaction: Option<String>,
50    /// The sample rate with which this trace was sampled in the client. This is a float between
51    /// `0.0` and `1.0`.
52    #[serde(
53        default,
54        with = "sample_rate_as_string",
55        skip_serializing_if = "Option::is_none"
56    )]
57    pub sample_rate: Option<f64>,
58    /// The user specific identifier (e.g. a user segment, or similar created by the SDK from the
59    /// user object).
60    #[serde(flatten, default)]
61    pub user: TraceUserContext,
62    /// If the event occurred during a session replay, the associated replay_id is added to the DSC.
63    pub replay_id: Option<Uuid>,
64    /// Set to true if the transaction starting the trace has been kept by client side sampling.
65    #[serde(
66        default,
67        deserialize_with = "deserialize_bool_option",
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub sampled: Option<bool>,
71    /// Additional arbitrary fields for forwards compatibility.
72    #[serde(flatten, default)]
73    pub other: BTreeMap<String, Value>,
74}
75
76impl Getter for DynamicSamplingContext {
77    fn get_value(&self, path: &str) -> Option<Val<'_>> {
78        Some(match path.strip_prefix("trace.")? {
79            "release" => self.release.as_deref()?.into(),
80            "environment" => self.environment.as_deref()?.into(),
81            "user.id" => or_none(&self.user.user_id)?.into(),
82            "user.segment" => or_none(&self.user.user_segment)?.into(),
83            "transaction" => self.transaction.as_deref()?.into(),
84            "replay_id" => self.replay_id.as_ref()?.into(),
85            _ => return None,
86        })
87    }
88}
89
90fn or_none(string: &impl AsRef<str>) -> Option<&str> {
91    match string.as_ref() {
92        "" => None,
93        other => Some(other),
94    }
95}
96
97/// User-related information in a [`DynamicSamplingContext`].
98#[derive(Debug, Clone, Serialize, Default)]
99pub struct TraceUserContext {
100    /// The value of the `user.segment` property.
101    #[serde(default, skip_serializing_if = "String::is_empty")]
102    pub user_segment: String,
103
104    /// The value of the `user.id` property.
105    #[serde(default, skip_serializing_if = "String::is_empty")]
106    pub user_id: String,
107}
108
109impl<'de> Deserialize<'de> for TraceUserContext {
110    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111    where
112        D: serde::Deserializer<'de>,
113    {
114        #[derive(Deserialize, Default)]
115        struct Nested {
116            #[serde(default)]
117            pub segment: String,
118            #[serde(default)]
119            pub id: String,
120        }
121
122        #[derive(Deserialize)]
123        struct Helper {
124            // Nested implements default, but we used to accept user=null (not sure if any SDK
125            // sends this though)
126            #[serde(default)]
127            user: Option<Nested>,
128            #[serde(default)]
129            user_segment: String,
130            #[serde(default)]
131            user_id: String,
132        }
133
134        let helper = Helper::deserialize(deserializer)?;
135
136        if helper.user_id.is_empty() && helper.user_segment.is_empty() {
137            let user = helper.user.unwrap_or_default();
138            Ok(TraceUserContext {
139                user_segment: user.segment,
140                user_id: user.id,
141            })
142        } else {
143            Ok(TraceUserContext {
144                user_segment: helper.user_segment,
145                user_id: helper.user_id,
146            })
147        }
148    }
149}
150
151mod sample_rate_as_string {
152    use std::borrow::Cow;
153
154    use serde::{Deserialize, Serialize};
155
156    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
157    where
158        D: serde::Deserializer<'de>,
159    {
160        #[derive(Debug, Clone, Deserialize)]
161        #[serde(untagged)]
162        enum StringOrFloat<'a> {
163            String(#[serde(borrow)] Cow<'a, str>),
164            Float(f64),
165        }
166
167        let value = match Option::<StringOrFloat>::deserialize(deserializer)? {
168            Some(value) => value,
169            None => return Ok(None),
170        };
171
172        let parsed_value = match value {
173            StringOrFloat::Float(f) => f,
174            StringOrFloat::String(s) => {
175                serde_json::from_str(&s).map_err(serde::de::Error::custom)?
176            }
177        };
178
179        if parsed_value < 0.0 {
180            return Err(serde::de::Error::custom("sample rate cannot be negative"));
181        }
182
183        Ok(Some(parsed_value))
184    }
185
186    pub fn serialize<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
187    where
188        S: serde::Serializer,
189    {
190        match value {
191            Some(float) => serde_json::to_string(float)
192                .map_err(|e| serde::ser::Error::custom(e.to_string()))?
193                .serialize(serializer),
194            None => value.serialize(serializer),
195        }
196    }
197}
198
199struct BoolOptionVisitor;
200
201impl serde::de::Visitor<'_> for BoolOptionVisitor {
202    type Value = Option<bool>;
203
204    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
205        formatter.write_str("`true` or `false` as boolean or string")
206    }
207
208    fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
209    where
210        E: serde::de::Error,
211    {
212        Ok(Some(v))
213    }
214
215    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
216    where
217        E: serde::de::Error,
218    {
219        Ok(match v {
220            "true" => Some(true),
221            "false" => Some(false),
222            _ => None,
223        })
224    }
225
226    fn visit_unit<E>(self) -> Result<Self::Value, E>
227    where
228        E: serde::de::Error,
229    {
230        Ok(None)
231    }
232}
233
234fn deserialize_bool_option<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
235where
236    D: serde::Deserializer<'de>,
237{
238    deserializer.deserialize_any(BoolOptionVisitor)
239}
240
241#[cfg(test)]
242mod tests {
243    use relay_protocol::HexId;
244
245    use super::*;
246
247    #[test]
248    fn parse_full() {
249        let json = include_str!("../tests/fixtures/dynamic_sampling_context.json");
250        serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
251    }
252
253    #[test]
254    /// Test parse user
255    fn parse_user() {
256        let jsons = [
257            r#"{
258                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
259                "public_key": "abd0f232775f45feab79864e580d160b",
260                "user": {
261                    "id": "some-id",
262                    "segment": "all"
263                }
264            }"#,
265            r#"{
266                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
267                "public_key": "abd0f232775f45feab79864e580d160b",
268                "user_id": "some-id",
269                "user_segment": "all"
270            }"#,
271            // testing some edgecases to see whether they behave as expected, but we don't actually
272            // rely on this behavior anywhere (ignoring Hyrum's law). it would be fine for them to
273            // change, we just have to be conscious about it.
274            r#"{
275                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
276                "public_key": "abd0f232775f45feab79864e580d160b",
277                "user_id": "",
278                "user_segment": "",
279                "user": {
280                    "id": "some-id",
281                    "segment": "all"
282                }
283            }"#,
284            r#"{
285                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
286                "public_key": "abd0f232775f45feab79864e580d160b",
287                "user_id": "some-id",
288                "user_segment": "all",
289                "user": {
290                    "id": "bogus-id",
291                    "segment": "nothing"
292                }
293            }"#,
294            r#"{
295                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
296                "public_key": "abd0f232775f45feab79864e580d160b",
297                "user_id": "some-id",
298                "user_segment": "all",
299                "user": null
300            }"#,
301        ];
302
303        for json in jsons {
304            let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
305            assert_eq!(dsc.user.user_id, "some-id");
306            assert_eq!(dsc.user.user_segment, "all");
307        }
308    }
309
310    #[test]
311    fn test_parse_user_partial() {
312        // in that case we might have two different sdks merging data and we at least shouldn't mix
313        // data together
314        let json = r#"
315        {
316            "trace_id": "b1e2a9dc-9b8e-4cd0-af0e-80e6b83b56e6",
317            "public_key": "abd0f232775f45feab79864e580d160b",
318            "user_id": "hello",
319            "user": {
320                "segment": "all"
321            }
322        }
323        "#;
324        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
325        insta::assert_ron_snapshot!(dsc, @r###"
326        {
327          "trace_id": "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
328          "public_key": "abd0f232775f45feab79864e580d160b",
329          "release": None,
330          "environment": None,
331          "transaction": None,
332          "user_id": "hello",
333          "replay_id": None,
334        }
335        "###);
336    }
337
338    #[test]
339    fn test_parse_sample_rate() {
340        let json = r#"
341        {
342            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
343            "public_key": "abd0f232775f45feab79864e580d160b",
344            "user_id": "hello",
345            "sample_rate": "0.5"
346        }
347        "#;
348        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
349        insta::assert_ron_snapshot!(dsc, @r###"
350        {
351          "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
352          "public_key": "abd0f232775f45feab79864e580d160b",
353          "release": None,
354          "environment": None,
355          "transaction": None,
356          "sample_rate": "0.5",
357          "user_id": "hello",
358          "replay_id": None,
359        }
360        "###);
361    }
362
363    #[test]
364    fn test_parse_sample_rate_scientific_notation() {
365        let json = r#"
366        {
367            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
368            "public_key": "abd0f232775f45feab79864e580d160b",
369            "user_id": "hello",
370            "sample_rate": "1e-5"
371        }
372        "#;
373        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
374        insta::assert_ron_snapshot!(dsc, @r###"
375        {
376          "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
377          "public_key": "abd0f232775f45feab79864e580d160b",
378          "release": None,
379          "environment": None,
380          "transaction": None,
381          "sample_rate": "0.00001",
382          "user_id": "hello",
383          "replay_id": None,
384        }
385        "###);
386    }
387
388    #[test]
389    fn test_parse_sample_rate_bogus() {
390        let json = r#"
391        {
392            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
393            "public_key": "abd0f232775f45feab79864e580d160b",
394            "user_id": "hello",
395            "sample_rate": "bogus"
396        }
397        "#;
398        serde_json::from_str::<DynamicSamplingContext>(json).unwrap_err();
399    }
400
401    #[test]
402    fn test_parse_sample_rate_number() {
403        let json = r#"
404        {
405            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
406            "public_key": "abd0f232775f45feab79864e580d160b",
407            "user_id": "hello",
408            "sample_rate": 0.1
409        }
410        "#;
411        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
412        insta::assert_ron_snapshot!(dsc, @r###"
413        {
414          "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
415          "public_key": "abd0f232775f45feab79864e580d160b",
416          "release": None,
417          "environment": None,
418          "transaction": None,
419          "sample_rate": "0.1",
420          "user_id": "hello",
421          "replay_id": None,
422        }
423        "###);
424    }
425
426    #[test]
427    fn test_parse_sample_rate_integer() {
428        let json = r#"
429            {
430                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
431                "public_key": "abd0f232775f45feab79864e580d160b",
432                "user_id": "hello",
433                "sample_rate": "1"
434            }
435        "#;
436        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
437        insta::assert_ron_snapshot!(dsc, @r###"
438        {
439          "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
440          "public_key": "abd0f232775f45feab79864e580d160b",
441          "release": None,
442          "environment": None,
443          "transaction": None,
444          "sample_rate": "1.0",
445          "user_id": "hello",
446          "replay_id": None,
447        }
448        "###);
449    }
450
451    #[test]
452    fn test_parse_sample_rate_negative() {
453        let json = r#"
454        {
455            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
456            "public_key": "abd0f232775f45feab79864e580d160b",
457            "user_id": "hello",
458            "sample_rate": "-0.1"
459        }
460        "#;
461        serde_json::from_str::<DynamicSamplingContext>(json).unwrap_err();
462    }
463
464    #[test]
465    fn test_parse_sampled_with_incoming_boolean() {
466        let json = r#"
467        {
468            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
469            "public_key": "abd0f232775f45feab79864e580d160b",
470            "user_id": "hello",
471            "sampled": true
472        }
473        "#;
474        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
475        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
476        let expected_json = r#"{
477  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
478  "public_key": "abd0f232775f45feab79864e580d160b",
479  "release": null,
480  "environment": null,
481  "transaction": null,
482  "user_id": "hello",
483  "replay_id": null,
484  "sampled": true
485}"#;
486
487        assert_eq!(dsc_as_json, expected_json);
488    }
489
490    #[test]
491    fn test_parse_sampled_with_incoming_boolean_as_string() {
492        let json = r#"
493        {
494            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
495            "public_key": "abd0f232775f45feab79864e580d160b",
496            "user_id": "hello",
497            "sampled": "false"
498        }
499        "#;
500        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
501        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
502        let expected_json = r#"{
503  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
504  "public_key": "abd0f232775f45feab79864e580d160b",
505  "release": null,
506  "environment": null,
507  "transaction": null,
508  "user_id": "hello",
509  "replay_id": null,
510  "sampled": false
511}"#;
512
513        assert_eq!(dsc_as_json, expected_json);
514    }
515
516    #[test]
517    fn test_parse_sampled_with_incoming_invalid_boolean_as_string() {
518        let json = r#"
519        {
520            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
521            "public_key": "abd0f232775f45feab79864e580d160b",
522            "user_id": "hello",
523            "sampled": "tru"
524        }
525        "#;
526        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
527        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
528        let expected_json = r#"{
529  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
530  "public_key": "abd0f232775f45feab79864e580d160b",
531  "release": null,
532  "environment": null,
533  "transaction": null,
534  "user_id": "hello",
535  "replay_id": null
536}"#;
537
538        assert_eq!(dsc_as_json, expected_json);
539    }
540
541    #[test]
542    fn test_parse_sampled_with_incoming_null_value() {
543        let json = r#"
544        {
545            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
546            "public_key": "abd0f232775f45feab79864e580d160b",
547            "user_id": "hello",
548            "sampled": null
549        }
550        "#;
551        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
552        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
553        let expected_json = r#"{
554  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
555  "public_key": "abd0f232775f45feab79864e580d160b",
556  "release": null,
557  "environment": null,
558  "transaction": null,
559  "user_id": "hello",
560  "replay_id": null
561}"#;
562
563        assert_eq!(dsc_as_json, expected_json);
564    }
565
566    #[test]
567    fn getter_filled() {
568        let replay_id = Uuid::new_v4();
569        let dsc = DynamicSamplingContext {
570            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
571            public_key: ProjectKey::parse("abd0f232775f45feab79864e580d160b").unwrap(),
572            project_id: Some(ProjectId::new(42)),
573            release: Some("1.1.1".into()),
574            user: TraceUserContext {
575                user_segment: "user-seg".into(),
576                user_id: "user-id".into(),
577            },
578            environment: Some("prod".into()),
579            transaction: Some("transaction1".into()),
580            sample_rate: None,
581            replay_id: Some(replay_id),
582            sampled: None,
583            other: BTreeMap::new(),
584        };
585
586        assert_eq!(Some(Val::String("1.1.1")), dsc.get_value("trace.release"));
587        assert_eq!(
588            Some(Val::String("prod")),
589            dsc.get_value("trace.environment")
590        );
591        assert_eq!(Some(Val::String("user-id")), dsc.get_value("trace.user.id"));
592        assert_eq!(
593            Some(Val::String("user-seg")),
594            dsc.get_value("trace.user.segment")
595        );
596        assert_eq!(
597            Some(Val::String("transaction1")),
598            dsc.get_value("trace.transaction")
599        );
600        assert_eq!(
601            Some(Val::HexId(HexId(replay_id.as_bytes()))),
602            dsc.get_value("trace.replay_id")
603        );
604    }
605
606    #[test]
607    fn getter_empty() {
608        let dsc = DynamicSamplingContext {
609            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
610            public_key: ProjectKey::parse("abd0f232775f45feab79864e580d160b").unwrap(),
611            project_id: Some(ProjectId::new(42)),
612            release: None,
613            user: TraceUserContext::default(),
614            environment: None,
615            transaction: None,
616            sample_rate: None,
617            replay_id: None,
618            sampled: None,
619            other: BTreeMap::new(),
620        };
621        assert_eq!(None, dsc.get_value("trace.release"));
622        assert_eq!(None, dsc.get_value("trace.environment"));
623        assert_eq!(None, dsc.get_value("trace.user.id"));
624        assert_eq!(None, dsc.get_value("trace.user.segment"));
625        assert_eq!(None, dsc.get_value("trace.user.transaction"));
626        assert_eq!(None, dsc.get_value("trace.replay_id"));
627
628        let dsc = DynamicSamplingContext {
629            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
630            public_key: ProjectKey::parse("abd0f232775f45feab79864e580d160b").unwrap(),
631            project_id: Some(ProjectId::new(42)),
632            release: None,
633            user: TraceUserContext::default(),
634            environment: None,
635            transaction: None,
636            sample_rate: None,
637            replay_id: None,
638            sampled: None,
639            other: BTreeMap::new(),
640        };
641        assert_eq!(None, dsc.get_value("trace.user.id"));
642        assert_eq!(None, dsc.get_value("trace.user.segment"));
643    }
644}