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