Skip to main content

relay_sampling/
evaluation.rs

1//! Evaluation of dynamic sampling rules.
2
3use std::fmt;
4use std::num::ParseIntError;
5use std::ops::ControlFlow;
6
7use chrono::{DateTime, Utc};
8use rand::Rng;
9use rand::distr::StandardUniform;
10use rand_pcg::Pcg32;
11use relay_protocol::Getter;
12use serde::Serialize;
13use uuid::Uuid;
14
15use crate::config::{RuleId, SamplingRule, SamplingValue};
16
17/// Generates a pseudo random number by seeding the generator with the given id.
18///
19/// The return is deterministic, always generates the same number from the same id.
20fn pseudo_random_from_seed(seed: Uuid) -> f64 {
21    let seed_number = seed.as_u128();
22    let mut generator = Pcg32::new((seed_number >> 64) as u64, seed_number as u64);
23    generator.sample(StandardUniform)
24}
25
26/// State machine for dynamic sampling.
27#[derive(Debug)]
28pub struct SamplingEvaluator {
29    now: DateTime<Utc>,
30    rule_ids: Vec<RuleId>,
31    factor: f64,
32    minimum_sample_rate: Option<f64>,
33}
34
35impl SamplingEvaluator {
36    /// Constructs an evaluator.
37    pub fn new(now: DateTime<Utc>) -> Self {
38        Self {
39            now,
40            rule_ids: vec![],
41            factor: 1.0,
42            minimum_sample_rate: None,
43        }
44    }
45
46    /// Attempts to find a match for sampling rules using `ControlFlow`.
47    ///
48    /// This function returns a `ControlFlow` to provide control over the matching process.
49    ///
50    /// - `ControlFlow::Continue`: Indicates that matching is incomplete, and more rules can be evaluated.
51    ///    - This state occurs either if no active rules match the provided data, or if the matched rules
52    ///      are factors requiring a final sampling value.
53    ///    - The returned evaluator contains the state of the matched rules and the accumulated sampling factor.
54    ///    - If this value is returned and there are no more rules to evaluate, it should be interpreted as "no match."
55    ///
56    /// - `ControlFlow::Break`: Indicates that one or more rules have successfully matched.
57    pub fn match_rules<'a, I, G>(
58        mut self,
59        seed: Uuid,
60        instance: &G,
61        rules: I,
62    ) -> ControlFlow<SamplingMatch, Self>
63    where
64        G: Getter,
65        I: Iterator<Item = &'a SamplingRule>,
66    {
67        for rule in rules {
68            if !rule.time_range.contains(self.now) || !rule.condition.matches(instance) {
69                continue;
70            };
71
72            if let Some(sample_rate) = self.try_compute_sample_rate(rule) {
73                return ControlFlow::Break(SamplingMatch::new(sample_rate, seed, self.rule_ids));
74            };
75        }
76
77        ControlFlow::Continue(self)
78    }
79
80    /// Attempts to compute the sample rate for a given [`SamplingRule`].
81    ///
82    /// # Returns
83    ///
84    /// - `None` if the sampling rule is invalid, expired, or if the final sample rate has not been
85    ///   determined yet.
86    /// - `Some` if the computed sample rate should be applied directly.
87    fn try_compute_sample_rate(&mut self, rule: &SamplingRule) -> Option<f64> {
88        match rule.sampling_value {
89            SamplingValue::Factor { value } => {
90                self.factor *= rule.apply_decaying_fn(value, self.now)?;
91                self.rule_ids.push(rule.id);
92                None
93            }
94            SamplingValue::SampleRate { value } => {
95                let sample_rate = rule.apply_decaying_fn(value, self.now)?;
96                let minimum_sample_rate = self.minimum_sample_rate.unwrap_or(0.0);
97                let adjusted = (sample_rate.max(minimum_sample_rate) * self.factor).clamp(0.0, 1.0);
98
99                self.rule_ids.push(rule.id);
100                Some(adjusted)
101            }
102            SamplingValue::MinimumSampleRate { value } => {
103                if self.minimum_sample_rate.is_none() {
104                    self.minimum_sample_rate = Some(rule.apply_decaying_fn(value, self.now)?);
105                    self.rule_ids.push(rule.id);
106                }
107                None
108            }
109        }
110    }
111}
112
113fn sampling_match(sample_rate: f64, seed: Uuid) -> SamplingDecision {
114    if sample_rate <= 0.0 {
115        return SamplingDecision::Drop;
116    } else if sample_rate >= 1.0 {
117        return SamplingDecision::Keep;
118    }
119
120    let random_number = pseudo_random_from_seed(seed);
121    relay_log::trace!(
122        sample_rate,
123        random_number,
124        "applying dynamic sampling to matching event"
125    );
126
127    if random_number >= sample_rate {
128        relay_log::trace!("dropping event that matched the configuration");
129        SamplingDecision::Drop
130    } else {
131        relay_log::trace!("keeping event that matched the configuration");
132        SamplingDecision::Keep
133    }
134}
135
136/// A sampling decision.
137#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
138pub enum SamplingDecision {
139    /// The item is sampled and should not be dropped.
140    Keep,
141    /// The item is not sampled and should be dropped.
142    Drop,
143}
144
145impl SamplingDecision {
146    /// Returns `true` if the sampling decision is [`Self::Keep`].
147    pub fn is_keep(self) -> bool {
148        matches!(self, Self::Keep)
149    }
150
151    /// Returns `true` if the sampling decision is [`Self::Drop`].
152    pub fn is_drop(self) -> bool {
153        matches!(self, Self::Drop)
154    }
155
156    /// Returns a string representation of the sampling decision.
157    pub fn as_str(self) -> &'static str {
158        match self {
159            Self::Keep => "keep",
160            Self::Drop => "drop",
161        }
162    }
163}
164
165impl fmt::Display for SamplingDecision {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(f, "{}", self.as_str())
168    }
169}
170
171/// Represents the specification for sampling an incoming event.
172#[derive(Clone, Debug, PartialEq)]
173pub struct SamplingMatch {
174    /// The sample rate to use for the incoming event.
175    sample_rate: f64,
176    /// The seed to feed to the random number generator which allows the same number to be
177    /// generated given the same seed.
178    ///
179    /// This is especially important for trace sampling, even though we can have inconsistent
180    /// traces due to multi-matching.
181    seed: Uuid,
182    /// The list of rule ids that have matched the incoming event and/or dynamic sampling context.
183    matched_rules: MatchedRuleIds,
184    /// Whether this sampling match results in the item getting sampled.
185    /// It's essentially a cache, as the value can be deterministically derived from
186    /// the sample rate and the seed.
187    decision: SamplingDecision,
188}
189
190impl SamplingMatch {
191    fn new(sample_rate: f64, seed: Uuid, matched_rules: Vec<RuleId>) -> Self {
192        let matched_rules = MatchedRuleIds(matched_rules);
193        let decision = sampling_match(sample_rate, seed);
194
195        Self {
196            sample_rate,
197            seed,
198            matched_rules,
199            decision,
200        }
201    }
202
203    /// Returns the sample rate.
204    pub fn sample_rate(&self) -> f64 {
205        self.sample_rate
206    }
207
208    /// Returns the matched rules for the sampling match.
209    ///
210    /// Takes ownership, useful if you don't need the [`SamplingMatch`] anymore
211    /// and you want to avoid allocations.
212    pub fn into_matched_rules(self) -> MatchedRuleIds {
213        self.matched_rules
214    }
215
216    /// Returns the sampling decision.
217    pub fn decision(&self) -> SamplingDecision {
218        self.decision
219    }
220}
221
222/// Represents a list of rule ids which is used for outcomes.
223#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
224pub struct MatchedRuleIds(pub Vec<RuleId>);
225
226impl MatchedRuleIds {
227    /// Parses `MatchedRuleIds` from a string with concatenated rule identifiers.
228    ///
229    /// The format it parses from is:
230    ///
231    /// ```text
232    /// rule_id_1,rule_id_2,...
233    /// ```
234    pub fn parse(value: &str) -> Result<MatchedRuleIds, ParseIntError> {
235        let mut rule_ids = vec![];
236
237        for rule_id in value.split(',') {
238            rule_ids.push(RuleId(rule_id.parse()?));
239        }
240
241        Ok(MatchedRuleIds(rule_ids))
242    }
243}
244
245impl fmt::Display for MatchedRuleIds {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        for (i, rule_id) in self.0.iter().enumerate() {
248            if i > 0 {
249                write!(f, ",")?;
250            }
251            write!(f, "{rule_id}")?;
252        }
253
254        Ok(())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use chrono::TimeZone;
261    use relay_base_schema::project::ProjectId;
262    use relay_protocol::RuleCondition;
263    use similar_asserts::assert_eq;
264    use std::str::FromStr;
265    use uuid::Uuid;
266
267    use crate::DynamicSamplingContext;
268    use crate::config::{DecayingFunction, RuleType, TimeRange};
269    use crate::dsc::TraceUserContext;
270
271    use super::*;
272
273    /// Helper to extract the sampling match after evaluating rules.
274    fn get_sampling_match(rules: &[SamplingRule], instance: &impl Getter) -> SamplingMatch {
275        match SamplingEvaluator::new(Utc::now()).match_rules(
276            Uuid::default(),
277            instance,
278            rules.iter(),
279        ) {
280            ControlFlow::Break(sampling_match) => sampling_match,
281            ControlFlow::Continue(_) => panic!("no match found"),
282        }
283    }
284
285    fn evaluation_is_match(res: ControlFlow<SamplingMatch, SamplingEvaluator>) -> bool {
286        matches!(res, ControlFlow::Break(_))
287    }
288
289    /// Helper to check if certain rules are matched on.
290    fn matches_rule_ids(rule_ids: &[u32], rules: &[SamplingRule], instance: &impl Getter) -> bool {
291        let matched_rule_ids = MatchedRuleIds(rule_ids.iter().map(|num| RuleId(*num)).collect());
292        let sampling_match = get_sampling_match(rules, instance);
293        matched_rule_ids == sampling_match.matched_rules
294    }
295
296    /// Helper function to create a dsc with the provided getter-values set.
297    fn mocked_dsc_with_getter_values(
298        paths_and_values: Vec<(&str, &str)>,
299    ) -> DynamicSamplingContext {
300        let mut dsc = DynamicSamplingContext {
301            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
302            public_key: "12345678123456781234567812345678".parse().unwrap(),
303            project_id: Some(ProjectId::new(42)),
304            release: None,
305            environment: None,
306            transaction: None,
307            sample_rate: None,
308            user: TraceUserContext::default(),
309            replay_id: None,
310            sampled: None,
311            other: Default::default(),
312        };
313
314        for (path, value) in paths_and_values {
315            match path {
316                "trace.release" => dsc.release = Some(value.to_owned()),
317                "trace.environment" => dsc.environment = Some(value.to_owned()),
318                "trace.user.id" => value.clone_into(&mut dsc.user.user_id),
319                "trace.user.segment" => value.clone_into(&mut dsc.user.user_segment),
320                "trace.transaction" => dsc.transaction = Some(value.to_owned()),
321                "trace.replay_id" => dsc.replay_id = Some(Uuid::from_str(value).unwrap()),
322                _ => panic!("invalid path"),
323            }
324        }
325
326        dsc
327    }
328
329    fn is_match(now: DateTime<Utc>, rule: &SamplingRule, dsc: &DynamicSamplingContext) -> bool {
330        SamplingEvaluator::new(now)
331            .match_rules(Uuid::default(), dsc, std::iter::once(rule))
332            .is_break()
333    }
334
335    #[test]
336    fn test_sample_rate_compounding() {
337        let rules = simple_sampling_rules(vec![
338            (RuleCondition::all(), SamplingValue::Factor { value: 0.8 }),
339            (RuleCondition::all(), SamplingValue::Factor { value: 0.5 }),
340            (
341                RuleCondition::all(),
342                SamplingValue::SampleRate { value: 0.25 },
343            ),
344        ]);
345        let dsc = mocked_dsc_with_getter_values(vec![]);
346
347        // 0.8 * 0.5 * 0.25 == 0.1
348        assert_eq!(get_sampling_match(&rules, &dsc).sample_rate(), 0.1);
349    }
350
351    #[test]
352    fn test_minimum_sample_rate() {
353        let rules = simple_sampling_rules(vec![
354            (RuleCondition::all(), SamplingValue::Factor { value: 1.5 }),
355            (
356                RuleCondition::all(),
357                SamplingValue::MinimumSampleRate { value: 0.5 },
358            ),
359            // Only the first matching minimum is applied.
360            (
361                RuleCondition::all(),
362                SamplingValue::MinimumSampleRate { value: 1.0 },
363            ),
364            (
365                RuleCondition::all(),
366                SamplingValue::SampleRate { value: 0.05 },
367            ),
368        ]);
369        let dsc = mocked_dsc_with_getter_values(vec![]);
370
371        // max(0.05, 0.5) * 1.5 = 0.75
372        assert_eq!(get_sampling_match(&rules, &dsc).sample_rate(), 0.75);
373    }
374
375    fn mocked_sampling_rule() -> SamplingRule {
376        SamplingRule {
377            condition: RuleCondition::all(),
378            sampling_value: SamplingValue::SampleRate { value: 1.0 },
379            ty: RuleType::Trace,
380            id: RuleId(0),
381            time_range: Default::default(),
382            decaying_fn: Default::default(),
383        }
384    }
385
386    /// Helper function to quickly construct many rules with their condition and value, and a unique id,
387    /// so the caller can easily check which rules are matching.
388    fn simple_sampling_rules(vals: Vec<(RuleCondition, SamplingValue)>) -> Vec<SamplingRule> {
389        let mut vec = vec![];
390
391        for (i, val) in vals.into_iter().enumerate() {
392            let (condition, sampling_value) = val;
393            vec.push(SamplingRule {
394                condition,
395                sampling_value,
396                ty: RuleType::Trace,
397                id: RuleId(i as u32),
398                time_range: Default::default(),
399                decaying_fn: Default::default(),
400            });
401        }
402        vec
403    }
404
405    /// Checks that rules don't match if the time is outside the time range.
406    #[test]
407    fn test_expired_rules() {
408        let rule = SamplingRule {
409            condition: RuleCondition::all(),
410            sampling_value: SamplingValue::SampleRate { value: 1.0 },
411            ty: RuleType::Trace,
412            id: RuleId(0),
413            time_range: TimeRange {
414                start: Some(Utc.with_ymd_and_hms(1970, 10, 10, 0, 0, 0).unwrap()),
415                end: Some(Utc.with_ymd_and_hms(1970, 10, 12, 0, 0, 0).unwrap()),
416            },
417            decaying_fn: Default::default(),
418        };
419
420        let dsc = mocked_dsc_with_getter_values(vec![]);
421
422        // Baseline test.
423        let within_timerange = Utc.with_ymd_and_hms(1970, 10, 11, 0, 0, 0).unwrap();
424        let res = SamplingEvaluator::new(within_timerange).match_rules(
425            Uuid::default(),
426            &dsc,
427            [rule.clone()].iter(),
428        );
429        assert!(evaluation_is_match(res));
430
431        let before_timerange = Utc.with_ymd_and_hms(1969, 1, 1, 0, 0, 0).unwrap();
432        let res = SamplingEvaluator::new(before_timerange).match_rules(
433            Uuid::default(),
434            &dsc,
435            [rule.clone()].iter(),
436        );
437        assert!(!evaluation_is_match(res));
438
439        let after_timerange = Utc.with_ymd_and_hms(1971, 1, 1, 0, 0, 0).unwrap();
440        let res = SamplingEvaluator::new(after_timerange).match_rules(
441            Uuid::default(),
442            &dsc,
443            [rule].iter(),
444        );
445        assert!(!evaluation_is_match(res));
446    }
447
448    /// Checks that `SamplingValueEvaluator` correctly matches the right rules.
449    #[test]
450    fn test_condition_matching() {
451        let rules = simple_sampling_rules(vec![
452            (
453                RuleCondition::glob("trace.transaction", "*healthcheck*"),
454                SamplingValue::SampleRate { value: 1.0 },
455            ),
456            (
457                RuleCondition::glob("trace.environment", "*dev*"),
458                SamplingValue::SampleRate { value: 1.0 },
459            ),
460            (
461                RuleCondition::eq_ignore_case("trace.transaction", "raboof"),
462                SamplingValue::Factor { value: 1.0 },
463            ),
464            (
465                RuleCondition::glob("trace.release", "1.1.1")
466                    & RuleCondition::eq_ignore_case("trace.user.segment", "vip"),
467                SamplingValue::SampleRate { value: 1.0 },
468            ),
469            (
470                RuleCondition::eq_ignore_case("trace.release", "1.1.1")
471                    & RuleCondition::eq_ignore_case("trace.environment", "prod"),
472                SamplingValue::Factor { value: 1.0 },
473            ),
474            (
475                RuleCondition::all(),
476                SamplingValue::SampleRate { value: 1.0 },
477            ),
478        ]);
479
480        // early return of first rule
481        let dsc = mocked_dsc_with_getter_values(vec![("trace.transaction", "foohealthcheckbar")]);
482        assert!(matches_rule_ids(&[0], &rules, &dsc));
483
484        // early return of second rule
485        let dsc = mocked_dsc_with_getter_values(vec![("trace.environment", "dev")]);
486        assert!(matches_rule_ids(&[1], &rules, &dsc));
487
488        // factor match third rule and early return sixth rule
489        let dsc = mocked_dsc_with_getter_values(vec![("trace.transaction", "raboof")]);
490        assert!(matches_rule_ids(&[2, 5], &rules, &dsc));
491
492        // factor match third rule and early return fourth rule
493        let dsc = mocked_dsc_with_getter_values(vec![
494            ("trace.transaction", "raboof"),
495            ("trace.release", "1.1.1"),
496            ("trace.user.segment", "vip"),
497        ]);
498        assert!(matches_rule_ids(&[2, 3], &rules, &dsc));
499
500        // factor match third, fifth rule and early return sixth rule
501        let dsc = mocked_dsc_with_getter_values(vec![
502            ("trace.transaction", "raboof"),
503            ("trace.release", "1.1.1"),
504            ("trace.environment", "prod"),
505        ]);
506        assert!(matches_rule_ids(&[2, 4, 5], &rules, &dsc));
507
508        // factor match fifth and early return sixth rule
509        let dsc = mocked_dsc_with_getter_values(vec![
510            ("trace.release", "1.1.1"),
511            ("trace.environment", "prod"),
512        ]);
513        assert!(matches_rule_ids(&[4, 5], &rules, &dsc));
514    }
515
516    #[test]
517    /// Test that we get the same sampling decision from the same trace id
518    fn test_repeatable_seed() {
519        let val1 = pseudo_random_from_seed(Uuid::default());
520        let val2 = pseudo_random_from_seed(Uuid::default());
521        assert!(val1 + f64::EPSILON > val2 && val2 + f64::EPSILON > val1);
522    }
523
524    #[test]
525    /// Tests if the MatchedRuleIds struct is displayed correctly as string.
526    fn matched_rule_ids_display() {
527        let matched_rule_ids = MatchedRuleIds(vec![RuleId(123), RuleId(456)]);
528        assert_eq!(matched_rule_ids.to_string(), "123,456");
529
530        let matched_rule_ids = MatchedRuleIds(vec![RuleId(123)]);
531        assert_eq!(matched_rule_ids.to_string(), "123");
532
533        let matched_rule_ids = MatchedRuleIds(vec![]);
534        assert_eq!(matched_rule_ids.to_string(), "")
535    }
536
537    #[test]
538    /// Tests if the MatchRuleIds struct is created correctly from its string representation.
539    fn matched_rule_ids_parse() {
540        assert_eq!(
541            MatchedRuleIds::parse("123,456"),
542            Ok(MatchedRuleIds(vec![RuleId(123), RuleId(456)]))
543        );
544
545        assert_eq!(
546            MatchedRuleIds::parse("123"),
547            Ok(MatchedRuleIds(vec![RuleId(123)]))
548        );
549
550        assert!(MatchedRuleIds::parse("").is_err());
551
552        assert!(MatchedRuleIds::parse(",").is_err());
553
554        assert!(MatchedRuleIds::parse("123.456").is_err());
555
556        assert!(MatchedRuleIds::parse("a,b").is_err());
557    }
558
559    #[test]
560    /// Tests that no match is done when there are no matching rules.
561    fn test_get_sampling_match_result_with_no_match() {
562        let dsc = mocked_dsc_with_getter_values(vec![]);
563
564        let res = SamplingEvaluator::new(Utc::now()).match_rules(Uuid::default(), &dsc, [].iter());
565
566        assert!(!evaluation_is_match(res));
567    }
568
569    /// Validates the early return (and hence no match) of the `match_rules` function if the current
570    /// time is out of bounds of the time range.
571    /// When the `start` or `end` of the range is missing, it defaults to always include
572    /// times before the `end` or after the `start`, respectively.
573    #[test]
574    fn test_sample_rate_valid_time_range() {
575        let dsc = mocked_dsc_with_getter_values(vec![]);
576        let time_range = TimeRange {
577            start: Some(Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()),
578            end: Some(Utc.with_ymd_and_hms(1980, 1, 1, 0, 0, 0).unwrap()),
579        };
580
581        let before_time_range = Utc.with_ymd_and_hms(1969, 1, 1, 0, 0, 0).unwrap();
582        let during_time_range = Utc.with_ymd_and_hms(1975, 1, 1, 0, 0, 0).unwrap();
583        let after_time_range = Utc.with_ymd_and_hms(1981, 1, 1, 0, 0, 0).unwrap();
584
585        let rule = SamplingRule {
586            condition: RuleCondition::all(),
587            sampling_value: SamplingValue::SampleRate { value: 1.0 },
588            ty: RuleType::Trace,
589            id: RuleId(0),
590            time_range,
591            decaying_fn: DecayingFunction::Constant,
592        };
593
594        // [start..end]
595        assert!(!is_match(before_time_range, &rule, &dsc));
596        assert!(is_match(during_time_range, &rule, &dsc));
597        assert!(!is_match(after_time_range, &rule, &dsc));
598
599        // [start..]
600        let mut rule_without_end = rule.clone();
601        rule_without_end.time_range.end = None;
602        assert!(!is_match(before_time_range, &rule_without_end, &dsc));
603        assert!(is_match(during_time_range, &rule_without_end, &dsc));
604        assert!(is_match(after_time_range, &rule_without_end, &dsc));
605
606        // [..end]
607        let mut rule_without_start = rule.clone();
608        rule_without_start.time_range.start = None;
609        assert!(is_match(before_time_range, &rule_without_start, &dsc));
610        assert!(is_match(during_time_range, &rule_without_start, &dsc));
611        assert!(!is_match(after_time_range, &rule_without_start, &dsc));
612
613        // [..]
614        let mut rule_without_range = rule.clone();
615        rule_without_range.time_range = TimeRange::default();
616        assert!(is_match(before_time_range, &rule_without_range, &dsc));
617        assert!(is_match(during_time_range, &rule_without_range, &dsc));
618        assert!(is_match(after_time_range, &rule_without_range, &dsc));
619    }
620
621    /// Checks that `validate_match` yields the correct controlflow given the SamplingValue variant.
622    #[test]
623    fn test_validate_match() {
624        let mut rule = mocked_sampling_rule();
625        let mut eval = SamplingEvaluator::new(Utc::now());
626
627        rule.sampling_value = SamplingValue::SampleRate { value: 1.0 };
628        assert_eq!(eval.try_compute_sample_rate(&rule), Some(1.0));
629
630        rule.sampling_value = SamplingValue::Factor { value: 1.0 };
631        assert_eq!(eval.try_compute_sample_rate(&rule), None);
632
633        rule.sampling_value = SamplingValue::MinimumSampleRate { value: 1.0 };
634        assert_eq!(eval.try_compute_sample_rate(&rule), None);
635    }
636}