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    /// A minimum sample rate.
167    ///
168    /// The sample rate specified in the rule will be used as a minimum over the otherwise used
169    /// sample rate.
170    ///
171    /// Only the first matching minimum sample rate will be applied.
172    MinimumSampleRate {
173        /// The minimum sample rate used to raise the chosen sample rate.
174        value: f64,
175    },
176}
177
178/// Defines what a dynamic sampling rule applies to.
179#[derive(Debug, Copy, Clone, Serialize, Deserialize, Eq, PartialEq)]
180#[serde(rename_all = "camelCase")]
181pub enum RuleType {
182    /// A trace rule matches on the [`DynamicSamplingContext`](crate::DynamicSamplingContext) and
183    /// applies to all transactions in a trace.
184    Trace,
185    /// A transaction rule matches directly on the transaction event independent of the trace.
186    Transaction,
187    // NOTE: If you add a new `RuleType` that is not supposed to sample transactions, you need to
188    // edit the `sample_envelope` function in `EnvelopeProcessorService`.
189    /// If the sampling config contains new rule types, do not sample at all.
190    #[serde(other)]
191    Unsupported,
192}
193
194/// The identifier of a [`SamplingRule`].
195///
196/// This number must be unique within a Sentry organization, as it is recorded in outcomes and used
197/// to infer which sampling rule caused data to be dropped.
198#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
199pub struct RuleId(pub u32);
200
201impl fmt::Display for RuleId {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.0)
204    }
205}
206
207/// A range of time.
208///
209/// The time range should be applicable between the start time, inclusive, and
210/// end time, exclusive. There aren't any explicit checks to ensure the end
211/// time is equal to or greater than the start time; the time range isn't valid
212/// in such cases.
213#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
214pub struct TimeRange {
215    /// The inclusive start of the time range.
216    pub start: Option<DateTime<Utc>>,
217
218    /// The exclusive end of the time range.
219    pub end: Option<DateTime<Utc>>,
220}
221
222impl TimeRange {
223    /// Returns true if neither the start nor end time limits are set.
224    pub fn is_empty(&self) -> bool {
225        self.start.is_none() && self.end.is_none()
226    }
227
228    /// Returns whether the provided time matches the time range.
229    ///
230    /// For a time to match a time range, the following conditions must match:
231    /// - The start time must be smaller than or equal to the given time, if provided.
232    /// - The end time must be greater than the given time, if provided.
233    ///
234    /// If one of the limits isn't provided, the range is considered open in
235    /// that limit. A time range open on both sides matches with any given time.
236    pub fn contains(&self, time: DateTime<Utc>) -> bool {
237        self.start.is_none_or(|s| s <= time) && self.end.is_none_or(|e| time < e)
238    }
239}
240
241/// Specifies how to interpolate sample rates for rules with bounded time window.
242#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244#[serde(tag = "type")]
245pub enum DecayingFunction {
246    /// Apply linear interpolation of the sample rate in the time window.
247    ///
248    /// The rule will start to apply with the configured sample rate at the beginning of the time
249    /// window and end with `decayed_value` at the end of the time window.
250    #[serde(rename_all = "camelCase")]
251    Linear {
252        /// The target value at the end of the time window.
253        decayed_value: f64,
254    },
255
256    /// Apply the sample rate of the rule for the full time window with hard cutoff.
257    #[default]
258    Constant,
259}
260
261impl DecayingFunction {
262    /// Applies the decaying function to the given sample rate.
263    pub fn adjust_sample_rate(
264        &self,
265        sample_rate: f64,
266        now: DateTime<Utc>,
267        time_range: TimeRange,
268    ) -> Option<f64> {
269        match self {
270            DecayingFunction::Linear { decayed_value } => {
271                let (Some(start), Some(end)) = (time_range.start, time_range.end) else {
272                    return None;
273                };
274
275                if sample_rate < *decayed_value {
276                    return None;
277                }
278
279                let now = now.timestamp() as f64;
280                let start = start.timestamp() as f64;
281                let end = end.timestamp() as f64;
282
283                let progress_ratio = ((now - start) / (end - start)).clamp(0.0, 1.0);
284
285                // This interval will always be < 0.
286                let interval = decayed_value - sample_rate;
287                Some(sample_rate + (interval * progress_ratio))
288            }
289            DecayingFunction::Constant => Some(sample_rate),
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use chrono::TimeZone;
297
298    use super::*;
299
300    #[test]
301    fn config_deserialize() {
302        let json = include_str!("../tests/fixtures/sampling_config.json");
303        serde_json::from_str::<SamplingConfig>(json).unwrap();
304    }
305
306    #[test]
307    fn test_supported() {
308        let rule: SamplingRule = serde_json::from_value(serde_json::json!({
309            "id": 1,
310            "type": "trace",
311            "samplingValue": {"type": "sampleRate", "value": 1.0},
312            "condition": {"op": "and", "inner": []}
313        }))
314        .unwrap();
315        assert!(rule.supported());
316    }
317
318    #[test]
319    fn test_unsupported_rule_type() {
320        let rule: SamplingRule = serde_json::from_value(serde_json::json!({
321            "id": 1,
322            "type": "new_rule_type_unknown_to_this_relay",
323            "samplingValue": {"type": "sampleRate", "value": 1.0},
324            "condition": {"op": "and", "inner": []}
325        }))
326        .unwrap();
327        assert!(!rule.supported());
328    }
329
330    #[test]
331    fn test_non_decaying_sampling_rule_deserialization() {
332        let serialized_rule = r#"{
333            "condition":{
334                "op":"and",
335                "inner": [
336                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
337                ]
338            },
339            "samplingValue": {"type": "sampleRate", "value": 0.7},
340            "type": "trace",
341            "id": 1
342        }"#;
343
344        let rule: SamplingRule = serde_json::from_str(serialized_rule).unwrap();
345        assert_eq!(
346            rule.sampling_value,
347            SamplingValue::SampleRate { value: 0.7f64 }
348        );
349        assert_eq!(rule.ty, RuleType::Trace);
350    }
351
352    #[test]
353    fn test_non_decaying_sampling_rule_deserialization_with_factor() {
354        let serialized_rule = r#"{
355            "condition":{
356                "op":"and",
357                "inner": [
358                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
359                ]
360            },
361            "samplingValue": {"type": "factor", "value": 5.0},
362            "type": "trace",
363            "id": 1
364        }"#;
365
366        let rule: SamplingRule = serde_json::from_str(serialized_rule).unwrap();
367        assert_eq!(rule.sampling_value, SamplingValue::Factor { value: 5.0 });
368        assert_eq!(rule.ty, RuleType::Trace);
369    }
370
371    #[test]
372    fn test_sampling_rule_with_constant_decaying_function_deserialization() {
373        let serialized_rule = r#"{
374            "condition":{
375                "op":"and",
376                "inner": [
377                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
378                ]
379            },
380            "samplingValue": {"type": "factor", "value": 5.0},
381            "type": "trace",
382            "id": 1,
383            "timeRange": {
384                "start": "2022-10-10T00:00:00.000000Z",
385                "end": "2022-10-20T00:00:00.000000Z"
386            }
387        }"#;
388        let rule: Result<SamplingRule, _> = serde_json::from_str(serialized_rule);
389        let rule = rule.unwrap();
390        let time_range = rule.time_range;
391        let decaying_function = rule.decaying_fn;
392
393        assert_eq!(
394            time_range.start,
395            Some(Utc.with_ymd_and_hms(2022, 10, 10, 0, 0, 0).unwrap())
396        );
397        assert_eq!(
398            time_range.end,
399            Some(Utc.with_ymd_and_hms(2022, 10, 20, 0, 0, 0).unwrap())
400        );
401        assert_eq!(decaying_function, DecayingFunction::Constant);
402    }
403
404    #[test]
405    fn test_sampling_rule_with_linear_decaying_function_deserialization() {
406        let serialized_rule = r#"{
407            "condition":{
408                "op":"and",
409                "inner": [
410                    { "op" : "glob", "name": "releases", "value":["1.1.1", "1.1.2"]}
411                ]
412            },
413            "samplingValue": {"type": "sampleRate", "value": 1.0},
414            "type": "trace",
415            "id": 1,
416            "timeRange": {
417                "start": "2022-10-10T00:00:00.000000Z",
418                "end": "2022-10-20T00:00:00.000000Z"
419            },
420            "decayingFn": {
421                "type": "linear",
422                "decayedValue": 0.9
423            }
424        }"#;
425        let rule: Result<SamplingRule, _> = serde_json::from_str(serialized_rule);
426        let rule = rule.unwrap();
427        let decaying_function = rule.decaying_fn;
428
429        assert_eq!(
430            decaying_function,
431            DecayingFunction::Linear { decayed_value: 0.9 }
432        );
433    }
434
435    #[test]
436    fn test_legacy_deserialization() {
437        let serialized_rule = r#"{
438               "rules": [],
439               "rulesV2": [
440                  {
441                     "samplingValue":{
442                        "type": "sampleRate",
443                        "value": 0.5
444                     },
445                     "type": "trace",
446                     "active": true,
447                     "condition": {
448                        "op": "and",
449                        "inner": []
450                     },
451                     "id": 1000
452                  }
453               ],
454               "mode": "received"
455        }"#;
456        let mut config: SamplingConfig = serde_json::from_str(serialized_rule).unwrap();
457        config.normalize();
458
459        // We want to make sure that we serialize an empty array of rule, irrespectively of the
460        // received payload.
461        assert_eq!(config.version, SAMPLING_CONFIG_VERSION);
462        assert_eq!(
463            config.rules[0].sampling_value,
464            SamplingValue::SampleRate { value: 0.5 }
465        );
466        assert!(config.rules_v2.is_empty());
467    }
468
469    #[test]
470    fn test_sampling_config_with_rules_and_rules_v2_serialization() {
471        let config = SamplingConfig {
472            rules: vec![SamplingRule {
473                condition: RuleCondition::all(),
474                sampling_value: SamplingValue::Factor { value: 2.0 },
475                ty: RuleType::Transaction,
476                id: RuleId(1),
477                time_range: Default::default(),
478                decaying_fn: Default::default(),
479            }],
480            ..SamplingConfig::new()
481        };
482
483        let serialized_config = serde_json::to_string_pretty(&config).unwrap();
484        let expected_serialized_config = r#"{
485  "version": 2,
486  "rules": [
487    {
488      "condition": {
489        "op": "and",
490        "inner": []
491      },
492      "samplingValue": {
493        "type": "factor",
494        "value": 2.0
495      },
496      "type": "transaction",
497      "id": 1
498    }
499  ]
500}"#;
501
502        assert_eq!(serialized_config, expected_serialized_config)
503    }
504
505    /// Checks that the sample rate stays constant if `DecayingFunction::Constant` is set.
506    #[test]
507    fn test_decay_fn_constant() {
508        let sample_rate = 0.5;
509
510        assert_eq!(
511            DecayingFunction::Constant.adjust_sample_rate(
512                sample_rate,
513                Utc::now(),
514                TimeRange::default()
515            ),
516            Some(sample_rate)
517        );
518    }
519
520    /// Checks if the sample rate decays linearly if `DecayingFunction::Linear` is set.
521    #[test]
522    fn test_decay_fn_linear() {
523        let decaying_fn = DecayingFunction::Linear { decayed_value: 0.5 };
524        let time_range = TimeRange {
525            start: Some(Utc.with_ymd_and_hms(1970, 10, 10, 0, 0, 0).unwrap()),
526            end: Some(Utc.with_ymd_and_hms(1970, 10, 12, 0, 0, 0).unwrap()),
527        };
528
529        let start = Utc.with_ymd_and_hms(1970, 10, 10, 0, 0, 0).unwrap();
530        let halfway = Utc.with_ymd_and_hms(1970, 10, 11, 0, 0, 0).unwrap();
531        let end = Utc.with_ymd_and_hms(1970, 10, 11, 23, 59, 59).unwrap();
532
533        // At the start of the time range, sample rate is equal to the rule's initial sampling value.
534        assert_eq!(
535            decaying_fn.adjust_sample_rate(1.0, start, time_range),
536            Some(1.0)
537        );
538
539        // Halfway in the time range, the value is exactly between 1.0 and 0.5.
540        assert_eq!(
541            decaying_fn.adjust_sample_rate(1.0, halfway, time_range),
542            Some(0.75)
543        );
544
545        // Approaches 0.5 at the end.
546        assert_eq!(
547            decaying_fn.adjust_sample_rate(1.0, end, time_range),
548            // It won't go to exactly 0.5 because the time range is end-exclusive.
549            Some(0.5000028935185186)
550        );
551
552        // If the end or beginning is missing, the linear decay shouldn't be run.
553        let mut time_range_without_start = time_range;
554        time_range_without_start.start = None;
555
556        assert!(
557            decaying_fn
558                .adjust_sample_rate(1.0, halfway, time_range_without_start)
559                .is_none()
560        );
561
562        let mut time_range_without_end = time_range;
563        time_range_without_end.end = None;
564
565        assert!(
566            decaying_fn
567                .adjust_sample_rate(1.0, halfway, time_range_without_end)
568                .is_none()
569        );
570    }
571}