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?.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 super::*;
237
238    #[test]
239    fn parse_full() {
240        let json = include_str!("../tests/fixtures/dynamic_sampling_context.json");
241        serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
242    }
243
244    #[test]
245    /// Test parse user
246    fn parse_user() {
247        let jsons = [
248            r#"{
249                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
250                "public_key": "abd0f232775f45feab79864e580d160b",
251                "user": {
252                    "id": "some-id",
253                    "segment": "all"
254                }
255            }"#,
256            r#"{
257                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
258                "public_key": "abd0f232775f45feab79864e580d160b",
259                "user_id": "some-id",
260                "user_segment": "all"
261            }"#,
262            // testing some edgecases to see whether they behave as expected, but we don't actually
263            // rely on this behavior anywhere (ignoring Hyrum's law). it would be fine for them to
264            // change, we just have to be conscious about it.
265            r#"{
266                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
267                "public_key": "abd0f232775f45feab79864e580d160b",
268                "user_id": "",
269                "user_segment": "",
270                "user": {
271                    "id": "some-id",
272                    "segment": "all"
273                }
274            }"#,
275            r#"{
276                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
277                "public_key": "abd0f232775f45feab79864e580d160b",
278                "user_id": "some-id",
279                "user_segment": "all",
280                "user": {
281                    "id": "bogus-id",
282                    "segment": "nothing"
283                }
284            }"#,
285            r#"{
286                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
287                "public_key": "abd0f232775f45feab79864e580d160b",
288                "user_id": "some-id",
289                "user_segment": "all",
290                "user": null
291            }"#,
292        ];
293
294        for json in jsons {
295            let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
296            assert_eq!(dsc.user.user_id, "some-id");
297            assert_eq!(dsc.user.user_segment, "all");
298        }
299    }
300
301    #[test]
302    fn test_parse_user_partial() {
303        // in that case we might have two different sdks merging data and we at least shouldn't mix
304        // data together
305        let json = r#"
306        {
307            "trace_id": "b1e2a9dc-9b8e-4cd0-af0e-80e6b83b56e6",
308            "public_key": "abd0f232775f45feab79864e580d160b",
309            "user_id": "hello",
310            "user": {
311                "segment": "all"
312            }
313        }
314        "#;
315        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
316        insta::assert_ron_snapshot!(dsc, @r#"
317        {
318          "trace_id": "b1e2a9dc9b8e4cd0af0e80e6b83b56e6",
319          "public_key": "abd0f232775f45feab79864e580d160b",
320          "release": None,
321          "environment": None,
322          "transaction": None,
323          "user_id": "hello",
324          "replay_id": None,
325        }
326        "#);
327    }
328
329    #[test]
330    fn test_parse_sample_rate() {
331        let json = r#"
332        {
333            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
334            "public_key": "abd0f232775f45feab79864e580d160b",
335            "user_id": "hello",
336            "sample_rate": "0.5"
337        }
338        "#;
339        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
340        insta::assert_ron_snapshot!(dsc, @r#"
341        {
342          "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
343          "public_key": "abd0f232775f45feab79864e580d160b",
344          "release": None,
345          "environment": None,
346          "transaction": None,
347          "sample_rate": "0.5",
348          "user_id": "hello",
349          "replay_id": None,
350        }
351        "#);
352    }
353
354    #[test]
355    fn test_parse_sample_rate_scientific_notation() {
356        let json = r#"
357        {
358            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
359            "public_key": "abd0f232775f45feab79864e580d160b",
360            "user_id": "hello",
361            "sample_rate": "1e-5"
362        }
363        "#;
364        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
365        insta::assert_ron_snapshot!(dsc, @r#"
366        {
367          "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
368          "public_key": "abd0f232775f45feab79864e580d160b",
369          "release": None,
370          "environment": None,
371          "transaction": None,
372          "sample_rate": "0.00001",
373          "user_id": "hello",
374          "replay_id": None,
375        }
376        "#);
377    }
378
379    #[test]
380    fn test_parse_sample_rate_bogus() {
381        let json = r#"
382        {
383            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
384            "public_key": "abd0f232775f45feab79864e580d160b",
385            "user_id": "hello",
386            "sample_rate": "bogus"
387        }
388        "#;
389        serde_json::from_str::<DynamicSamplingContext>(json).unwrap_err();
390    }
391
392    #[test]
393    fn test_parse_sample_rate_number() {
394        let json = r#"
395        {
396            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
397            "public_key": "abd0f232775f45feab79864e580d160b",
398            "user_id": "hello",
399            "sample_rate": 0.1
400        }
401        "#;
402        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
403        insta::assert_ron_snapshot!(dsc, @r#"
404            {
405              "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
406              "public_key": "abd0f232775f45feab79864e580d160b",
407              "release": None,
408              "environment": None,
409              "transaction": None,
410              "sample_rate": "0.1",
411              "user_id": "hello",
412              "replay_id": None,
413            }
414        "#);
415    }
416
417    #[test]
418    fn test_parse_sample_rate_integer() {
419        let json = r#"
420            {
421                "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
422                "public_key": "abd0f232775f45feab79864e580d160b",
423                "user_id": "hello",
424                "sample_rate": "1"
425            }
426        "#;
427        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
428        insta::assert_ron_snapshot!(dsc, @r#"
429            {
430              "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
431              "public_key": "abd0f232775f45feab79864e580d160b",
432              "release": None,
433              "environment": None,
434              "transaction": None,
435              "sample_rate": "1.0",
436              "user_id": "hello",
437              "replay_id": None,
438            }
439        "#);
440    }
441
442    #[test]
443    fn test_parse_sample_rate_negative() {
444        let json = r#"
445        {
446            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
447            "public_key": "abd0f232775f45feab79864e580d160b",
448            "user_id": "hello",
449            "sample_rate": "-0.1"
450        }
451        "#;
452        serde_json::from_str::<DynamicSamplingContext>(json).unwrap_err();
453    }
454
455    #[test]
456    fn test_parse_sampled_with_incoming_boolean() {
457        let json = r#"
458        {
459            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
460            "public_key": "abd0f232775f45feab79864e580d160b",
461            "user_id": "hello",
462            "sampled": true
463        }
464        "#;
465        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
466        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
467        let expected_json = r#"{
468  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
469  "public_key": "abd0f232775f45feab79864e580d160b",
470  "release": null,
471  "environment": null,
472  "transaction": null,
473  "user_id": "hello",
474  "replay_id": null,
475  "sampled": true
476}"#;
477
478        assert_eq!(dsc_as_json, expected_json);
479    }
480
481    #[test]
482    fn test_parse_sampled_with_incoming_boolean_as_string() {
483        let json = r#"
484        {
485            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
486            "public_key": "abd0f232775f45feab79864e580d160b",
487            "user_id": "hello",
488            "sampled": "false"
489        }
490        "#;
491        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
492        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
493        let expected_json = r#"{
494  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
495  "public_key": "abd0f232775f45feab79864e580d160b",
496  "release": null,
497  "environment": null,
498  "transaction": null,
499  "user_id": "hello",
500  "replay_id": null,
501  "sampled": false
502}"#;
503
504        assert_eq!(dsc_as_json, expected_json);
505    }
506
507    #[test]
508    fn test_parse_sampled_with_incoming_invalid_boolean_as_string() {
509        let json = r#"
510        {
511            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
512            "public_key": "abd0f232775f45feab79864e580d160b",
513            "user_id": "hello",
514            "sampled": "tru"
515        }
516        "#;
517        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
518        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
519        let expected_json = r#"{
520  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
521  "public_key": "abd0f232775f45feab79864e580d160b",
522  "release": null,
523  "environment": null,
524  "transaction": null,
525  "user_id": "hello",
526  "replay_id": null
527}"#;
528
529        assert_eq!(dsc_as_json, expected_json);
530    }
531
532    #[test]
533    fn test_parse_sampled_with_incoming_null_value() {
534        let json = r#"
535        {
536            "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
537            "public_key": "abd0f232775f45feab79864e580d160b",
538            "user_id": "hello",
539            "sampled": null
540        }
541        "#;
542        let dsc = serde_json::from_str::<DynamicSamplingContext>(json).unwrap();
543        let dsc_as_json = serde_json::to_string_pretty(&dsc).unwrap();
544        let expected_json = r#"{
545  "trace_id": "67e5504410b1426f9247bb680e5fe0c8",
546  "public_key": "abd0f232775f45feab79864e580d160b",
547  "release": null,
548  "environment": null,
549  "transaction": null,
550  "user_id": "hello",
551  "replay_id": null
552}"#;
553
554        assert_eq!(dsc_as_json, expected_json);
555    }
556
557    #[test]
558    fn getter_filled() {
559        let replay_id = Uuid::new_v4();
560        let dsc = DynamicSamplingContext {
561            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
562            public_key: ProjectKey::parse("abd0f232775f45feab79864e580d160b").unwrap(),
563            release: Some("1.1.1".into()),
564            user: TraceUserContext {
565                user_segment: "user-seg".into(),
566                user_id: "user-id".into(),
567            },
568            environment: Some("prod".into()),
569            transaction: Some("transaction1".into()),
570            sample_rate: None,
571            replay_id: Some(replay_id),
572            sampled: None,
573            other: BTreeMap::new(),
574        };
575
576        assert_eq!(Some(Val::String("1.1.1")), dsc.get_value("trace.release"));
577        assert_eq!(
578            Some(Val::String("prod")),
579            dsc.get_value("trace.environment")
580        );
581        assert_eq!(Some(Val::String("user-id")), dsc.get_value("trace.user.id"));
582        assert_eq!(
583            Some(Val::String("user-seg")),
584            dsc.get_value("trace.user.segment")
585        );
586        assert_eq!(
587            Some(Val::String("transaction1")),
588            dsc.get_value("trace.transaction")
589        );
590        assert_eq!(Some(Val::Uuid(replay_id)), dsc.get_value("trace.replay_id"));
591    }
592
593    #[test]
594    fn getter_empty() {
595        let dsc = DynamicSamplingContext {
596            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
597            public_key: ProjectKey::parse("abd0f232775f45feab79864e580d160b").unwrap(),
598            release: None,
599            user: TraceUserContext::default(),
600            environment: None,
601            transaction: None,
602            sample_rate: None,
603            replay_id: None,
604            sampled: None,
605            other: BTreeMap::new(),
606        };
607        assert_eq!(None, dsc.get_value("trace.release"));
608        assert_eq!(None, dsc.get_value("trace.environment"));
609        assert_eq!(None, dsc.get_value("trace.user.id"));
610        assert_eq!(None, dsc.get_value("trace.user.segment"));
611        assert_eq!(None, dsc.get_value("trace.user.transaction"));
612        assert_eq!(None, dsc.get_value("trace.replay_id"));
613
614        let dsc = DynamicSamplingContext {
615            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
616            public_key: ProjectKey::parse("abd0f232775f45feab79864e580d160b").unwrap(),
617            release: None,
618            user: TraceUserContext::default(),
619            environment: None,
620            transaction: None,
621            sample_rate: None,
622            replay_id: None,
623            sampled: None,
624            other: BTreeMap::new(),
625        };
626        assert_eq!(None, dsc.get_value("trace.user.id"));
627        assert_eq!(None, dsc.get_value("trace.user.segment"));
628    }
629}