relay_sampling/
config.rs

1//! Dynamic sampling rule configuration.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use relay_protocol::RuleCondition;
9
10/// Maximum supported version of dynamic sampling.
11///
12/// The version is an integer scalar, incremented by one on each new version:
13///  - 1: Initial version that uses `rules_v2`.
14///  - 2: Moves back to `rules` and adds support for `RuleConfigs` with string comparisons.
15const SAMPLING_CONFIG_VERSION: u16 = 2;
16
17/// Represents the dynamic sampling configuration available to a project.
18///
19/// Note: This comes from the organization data
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct SamplingConfig {
23    /// The required version to run dynamic sampling.
24    ///
25    /// Defaults to legacy version (`1`) when missing.
26    #[serde(default = "SamplingConfig::legacy_version")]
27    pub version: u16,
28
29    /// The ordered sampling rules for the project.
30    #[serde(default)]
31    pub rules: Vec<SamplingRule>,
32
33    /// **Deprecated**. The ordered sampling rules for the project in legacy format.
34    ///
35    /// Removed in favor of `Self::rules` in version `2`. This field remains here to parse rules
36    /// from old Sentry instances and convert them into the new format. The legacy format contained
37    /// both an empty `rules` as well as the actual rules in `rules_v2`. During normalization, these
38    /// two arrays are merged together.
39    #[serde(default, skip_serializing)]
40    pub rules_v2: Vec<SamplingRule>,
41}
42
43impl SamplingConfig {
44    /// Creates an enabled configuration with empty defaults and the latest version.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Returns `true` if any of the rules in this configuration is unsupported.
50    pub fn unsupported(&self) -> bool {
51        debug_assert!(self.version > 1, "SamplingConfig not normalized");
52        self.version > SAMPLING_CONFIG_VERSION || !self.rules.iter().all(SamplingRule::supported)
53    }
54
55    /// Filters the sampling rules by the given [`RuleType`].
56    pub fn filter_rules(&self, rule_type: RuleType) -> impl Iterator<Item = &SamplingRule> {
57        self.rules.iter().filter(move |rule| rule.ty == rule_type)
58    }
59
60    /// Upgrades legacy sampling configs into the latest format.
61    pub fn normalize(&mut self) {
62        if self.version == Self::legacy_version() {
63            self.rules.append(&mut self.rules_v2);
64            self.version = SAMPLING_CONFIG_VERSION;
65        }
66    }
67
68    const fn legacy_version() -> u16 {
69        1
70    }
71}
72
73impl Default for SamplingConfig {
74    fn default() -> Self {
75        Self {
76            version: SAMPLING_CONFIG_VERSION,
77            rules: vec![],
78            rules_v2: vec![],
79        }
80    }
81}
82
83/// A sampling rule as it is deserialized from the project configuration.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct SamplingRule {
87    /// A condition to match for this sampling rule.
88    ///
89    /// Sampling rules do not run if their condition does not match.
90    pub condition: RuleCondition,
91
92    /// The sample rate to apply when this rule matches.
93    pub sampling_value: SamplingValue,
94
95    /// The rule type declares what to apply a dynamic sampling rule to and how.
96    #[serde(rename = "type")]
97    pub ty: RuleType,
98
99    /// The unique identifier of this rule.
100    pub id: RuleId,
101
102    /// The time range the rule should be applicable in.
103    ///
104    /// The time range is open on both ends by default. If a time range is
105    /// closed on at least one end, the rule is considered a decaying rule.
106    #[serde(default, skip_serializing_if = "TimeRange::is_empty")]
107    pub time_range: TimeRange,
108
109    /// Declares how to interpolate the sample rate for rules with bounded time range.
110    #[serde(default, skip_serializing_if = "is_default")]
111    pub decaying_fn: DecayingFunction,
112}
113
114impl SamplingRule {
115    fn supported(&self) -> bool {
116        self.condition.supported() && self.ty != RuleType::Unsupported
117    }
118
119    /// Applies its decaying function to the given sample rate.
120    pub fn apply_decaying_fn(&self, sample_rate: f64, now: DateTime<Utc>) -> Option<f64> {
121        self.decaying_fn
122            .adjust_sample_rate(sample_rate, now, self.time_range)
123    }
124}
125
126/// Returns `true` if this value is equal to `Default::default()`.
127fn is_default<T: Default + PartialEq>(t: &T) -> bool {
128    *t == T::default()
129}
130
131/// A sampling strategy definition.
132///
133/// A sampling strategy refers to the strategy that we want to use for sampling a specific rule.
134#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136#[serde(tag = "type")]
137pub enum SamplingValue {
138    /// A direct sample rate to apply.
139    ///
140    /// A rule with a sample rate will be matched and the final sample rate will be computed by
141    /// multiplying its sample rate with the accumulated factors from previous rules.
142    SampleRate {
143        /// The sample rate to apply to the rule.
144        value: f64,
145    },
146
147    /// A factor to apply on a subsequently matching rule.
148    ///
149    /// A rule with a factor will be matched and the matching will continue onto the next rules
150    /// until a sample rate rule is found. The matched rule's factor will be multiplied with the
151    /// accumulated factors before moving onto the next possible match.
152    Factor {
153        /// The factor to apply on another matched sample rate.
154        value: f64,
155    },
156
157    /// A reservoir limit.
158    ///
159    /// A rule with a reservoir limit will be sampled if the rule have been matched fewer times
160    /// than the limit.
161    Reservoir {
162        /// The limit of how many times this rule will be sampled before this rule is invalid.
163        limit: i64,
164    },
165}
166
167/// Defines what a dynamic sampling rule applies to.
168#[derive(Debug, Copy, Clone, Serialize, Deserialize, Eq, PartialEq)]
169#[serde(rename_all = "camelCase")]
170pub enum RuleType {
171    /// A trace rule matches on the [`DynamicSamplingContext`](crate::DynamicSamplingContext) and
172    /// applies to all transactions in a trace.
173    Trace,
174    /// A transaction rule matches directly on the transaction event independent of the trace.
175    Transaction,
176    // NOTE: If you add a new `RuleType` that is not supposed to sample transactions, you need to
177    // edit the `sample_envelope` function in `EnvelopeProcessorService`.
178    /// If the sampling config contains new rule types, do not sample at all.
179    #[serde(other)]
180    Unsupported,
181}
182
183/// The identifier of a [`SamplingRule`].
184///
185/// This number must be unique within a Sentry organization, as it is recorded in outcomes and used
186/// to infer which sampling rule caused data to be dropped.
187#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
188pub struct RuleId(pub u32);
189
190impl fmt::Display for RuleId {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        write!(f, "{}", self.0)
193    }
194}
195
196/// A range of time.
197///
198/// The time range should be applicable between the start time, inclusive, and
199/// end time, exclusive. There aren't any explicit checks to ensure the end
200/// time is equal to or greater than the start time; the time range isn't valid
201/// in such cases.
202#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
203pub struct TimeRange {
204    /// The inclusive start of the time range.
205    pub start: Option<DateTime<Utc>>,
206
207    /// The exclusive end of the time range.
208    pub end: Option<DateTime<Utc>>,
209}
210
211impl TimeRange {
212    /// Returns true if neither the start nor end time limits are set.
213    pub fn is_empty(&self) -> bool {
214        self.start.is_none() && self.end.is_none()
215    }
216
217    /// Returns whether the provided time matches the time range.
218    ///
219    /// For a time to match a time range, the following conditions must match:
220    /// - The start time must be smaller than or equal to the given time, if provided.
221    /// - The end time must be greater than the given time, if provided.
222    ///
223    /// If one of the limits isn't provided, the range is considered open in
224    /// that limit. A time range open on both sides matches with any given time.
225    pub fn contains(&self, time: DateTime<Utc>) -> bool {
226        self.start.is_none_or(|s| s <= time) && self.end.is_none_or(|e| time < e)
227    }
228}
229
230/// Specifies how to interpolate sample rates for rules with bounded time window.
231#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233#[serde(tag = "type")]
234pub enum DecayingFunction {
235    /// Apply linear interpolation of the sample rate in the time window.
236    ///
237    /// The rule will start to apply with the configured sample rate at the beginning of the time
238    /// window and end with `decayed_value` at the end of the time window.
239    #[serde(rename_all = "camelCase")]
240    Linear {
241        /// The target value at the end of the time window.
242        decayed_value: f64,
243    },
244
245    /// Apply the sample rate of the rule for the full time window with hard cutoff.
246    #[default]
247    Constant,
248}
249
250impl DecayingFunction {
251    /// Applies the decaying function to the given sample rate.
252    pub fn adjust_sample_rate(
253        &self,
254        sample_rate: f64,
255        now: DateTime<Utc>,
256        time_range: TimeRange,
257    ) -> Option<f64> {
258        match self {
259            DecayingFunction::Linear { decayed_value } => {
260                let (Some(start), Some(end)) = (time_range.start, time_range.end) else {
261                    return None;
262                };
263
264                if sample_rate < *decayed_value {
265                    return None;
266                }
267
268                let now = now.timestamp() as f64;
269                let start = start.timestamp() as f64;
270                let end = end.timestamp() as f64;
271
272                let progress_ratio = ((now - start) / (end - start)).clamp(0.0, 1.0);
273
274                // This interval will always be < 0.
275                let interval = decayed_value - sample_rate;
276                Some(sample_rate + (interval * progress_ratio))
277            }
278            DecayingFunction::Constant => Some(sample_rate),
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use chrono::TimeZone;
286
287    use super::*;
288
289    #[test]
290    fn config_deserialize() {
291        let json = include_str!("../tests/fixtures/sampling_config.json");
292        serde_json::from_str::<SamplingConfig>(json).unwrap();
293    }
294
295    #[test]
296    fn test_supported() {
297        let rule: SamplingRule = serde_json::from_value(serde_json::json!({
298            "id": 1,
299            "type": "trace",
300            "samplingValue": {"type": "sampleRate", "value": 1.0},
301            "condition": {"op": "and", "inner": []}
302        }))
303        .unwrap();
304        assert!(rule.supported());
305    }
306
307    #[test]
308    fn test_unsupported_rule_type() {
309        let rule: SamplingRule = serde_json::from_value(serde_json::json!({
310            "id": 1,
311            "type": "new_rule_type_unknown_to_this_relay",
312            "samplingValue": {"type": "sampleRate", "value": 1.0},
313            "condition": {"op": "and", "inner": []}
314        }))
315        .unwrap();
316        assert!(!rule.supported());
317    }
318
319    #[test]
320    fn test_non_decaying_sampling_rule_deserialization() {
321        let serialized_rule = r#"{
322            "condition":{
323                "op":"and",
324                "inner": [
325                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
326                ]
327            },
328            "samplingValue": {"type": "sampleRate", "value": 0.7},
329            "type": "trace",
330            "id": 1
331        }"#;
332
333        let rule: SamplingRule = serde_json::from_str(serialized_rule).unwrap();
334        assert_eq!(
335            rule.sampling_value,
336            SamplingValue::SampleRate { value: 0.7f64 }
337        );
338        assert_eq!(rule.ty, RuleType::Trace);
339    }
340
341    #[test]
342    fn test_non_decaying_sampling_rule_deserialization_with_factor() {
343        let serialized_rule = r#"{
344            "condition":{
345                "op":"and",
346                "inner": [
347                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
348                ]
349            },
350            "samplingValue": {"type": "factor", "value": 5.0},
351            "type": "trace",
352            "id": 1
353        }"#;
354
355        let rule: SamplingRule = serde_json::from_str(serialized_rule).unwrap();
356        assert_eq!(rule.sampling_value, SamplingValue::Factor { value: 5.0 });
357        assert_eq!(rule.ty, RuleType::Trace);
358    }
359
360    #[test]
361    fn test_sampling_rule_with_constant_decaying_function_deserialization() {
362        let serialized_rule = r#"{
363            "condition":{
364                "op":"and",
365                "inner": [
366                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
367                ]
368            },
369            "samplingValue": {"type": "factor", "value": 5.0},
370            "type": "trace",
371            "id": 1,
372            "timeRange": {
373                "start": "2022-10-10T00:00:00.000000Z",
374                "end": "2022-10-20T00:00:00.000000Z"
375            }
376        }"#;
377        let rule: Result<SamplingRule, _> = serde_json::from_str(serialized_rule);
378        let rule = rule.unwrap();
379        let time_range = rule.time_range;
380        let decaying_function = rule.decaying_fn;
381
382        assert_eq!(
383            time_range.start,
384            Some(Utc.with_ymd_and_hms(2022, 10, 10, 0, 0, 0).unwrap())
385        );
386        assert_eq!(
387            time_range.end,
388            Some(Utc.with_ymd_and_hms(2022, 10, 20, 0, 0, 0).unwrap())
389        );
390        assert_eq!(decaying_function, DecayingFunction::Constant);
391    }
392
393    #[test]
394    fn test_sampling_rule_with_linear_decaying_function_deserialization() {
395        let serialized_rule = r#"{
396            "condition":{
397                "op":"and",
398                "inner": [
399                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
400                ]
401            },
402            "samplingValue": {"type": "sampleRate", "value": 1.0},
403            "type": "trace",
404            "id": 1,
405            "timeRange": {
406                "start": "2022-10-10T00:00:00.000000Z",
407                "end": "2022-10-20T00:00:00.000000Z"
408            },
409            "decayingFn": {
410                "type": "linear",
411                "decayedValue": 0.9
412            }
413        }"#;
414        let rule: Result<SamplingRule, _> = serde_json::from_str(serialized_rule);
415        let rule = rule.unwrap();
416        let decaying_function = rule.decaying_fn;
417
418        assert_eq!(
419            decaying_function,
420            DecayingFunction::Linear { decayed_value: 0.9 }
421        );
422    }
423
424    #[test]
425    fn test_legacy_deserialization() {
426        let serialized_rule = r#"{
427               "rules": [],
428               "rulesV2": [
429                  {
430                     "samplingValue":{
431                        "type": "sampleRate",
432                        "value": 0.5
433                     },
434                     "type": "trace",
435                     "active": true,
436                     "condition": {
437                        "op": "and",
438                        "inner": []
439                     },
440                     "id": 1000
441                  }
442               ],
443               "mode": "received"
444        }"#;
445        let mut config: SamplingConfig = serde_json::from_str(serialized_rule).unwrap();
446        config.normalize();
447
448        // We want to make sure that we serialize an empty array of rule, irrespectively of the
449        // received payload.
450        assert_eq!(config.version, SAMPLING_CONFIG_VERSION);
451        assert_eq!(
452            config.rules[0].sampling_value,
453            SamplingValue::SampleRate { value: 0.5 }
454        );
455        assert!(config.rules_v2.is_empty());
456    }
457
458    #[test]
459    fn test_sampling_config_with_rules_and_rules_v2_serialization() {
460        let config = SamplingConfig {
461            rules: vec![SamplingRule {
462                condition: RuleCondition::all(),
463                sampling_value: SamplingValue::Factor { value: 2.0 },
464                ty: RuleType::Transaction,
465                id: RuleId(1),
466                time_range: Default::default(),
467                decaying_fn: Default::default(),
468            }],
469            ..SamplingConfig::new()
470        };
471
472        let serialized_config = serde_json::to_string_pretty(&config).unwrap();
473        let expected_serialized_config = r#"{
474  "version": 2,
475  "rules": [
476    {
477      "condition": {
478        "op": "and",
479        "inner": []
480      },
481      "samplingValue": {
482        "type": "factor",
483        "value": 2.0
484      },
485      "type": "transaction",
486      "id": 1
487    }
488  ]
489}"#;
490
491        assert_eq!(serialized_config, expected_serialized_config)
492    }
493
494    /// Checks that the sample rate stays constant if `DecayingFunction::Constant` is set.
495    #[test]
496    fn test_decay_fn_constant() {
497        let sample_rate = 0.5;
498
499        assert_eq!(
500            DecayingFunction::Constant.adjust_sample_rate(
501                sample_rate,
502                Utc::now(),
503                TimeRange::default()
504            ),
505            Some(sample_rate)
506        );
507    }
508
509    /// Checks if the sample rate decays linearly if `DecayingFunction::Linear` is set.
510    #[test]
511    fn test_decay_fn_linear() {
512        let decaying_fn = DecayingFunction::Linear { decayed_value: 0.5 };
513        let time_range = TimeRange {
514            start: Some(Utc.with_ymd_and_hms(1970, 10, 10, 0, 0, 0).unwrap()),
515            end: Some(Utc.with_ymd_and_hms(1970, 10, 12, 0, 0, 0).unwrap()),
516        };
517
518        let start = Utc.with_ymd_and_hms(1970, 10, 10, 0, 0, 0).unwrap();
519        let halfway = Utc.with_ymd_and_hms(1970, 10, 11, 0, 0, 0).unwrap();
520        let end = Utc.with_ymd_and_hms(1970, 10, 11, 23, 59, 59).unwrap();
521
522        // At the start of the time range, sample rate is equal to the rule's initial sampling value.
523        assert_eq!(
524            decaying_fn.adjust_sample_rate(1.0, start, time_range),
525            Some(1.0)
526        );
527
528        // Halfway in the time range, the value is exactly between 1.0 and 0.5.
529        assert_eq!(
530            decaying_fn.adjust_sample_rate(1.0, halfway, time_range),
531            Some(0.75)
532        );
533
534        // Approaches 0.5 at the end.
535        assert_eq!(
536            decaying_fn.adjust_sample_rate(1.0, end, time_range),
537            // It won't go to exactly 0.5 because the time range is end-exclusive.
538            Some(0.5000028935185186)
539        );
540
541        // If the end or beginning is missing, the linear decay shouldn't be run.
542        let mut time_range_without_start = time_range;
543        time_range_without_start.start = None;
544
545        assert!(decaying_fn
546            .adjust_sample_rate(1.0, halfway, time_range_without_start)
547            .is_none());
548
549        let mut time_range_without_end = time_range;
550        time_range_without_end.end = None;
551
552        assert!(decaying_fn
553            .adjust_sample_rate(1.0, halfway, time_range_without_end)
554            .is_none());
555    }
556}