Skip to main content

libdd_sampling/
sampling_rule.rs

1// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::constants::pattern::NO_RULE;
5use crate::glob_matcher::GlobMatcher;
6use crate::rate_sampler::RateSampler;
7use crate::sampling_rule_config::SamplingRuleConfig;
8use crate::types::{AttributeLike, SpanProperties, TraceIdLike, ValueLike};
9use std::collections::HashMap;
10
11// HTTP status code attribute constants
12const HTTP_RESPONSE_STATUS_CODE: &str = "http.response.status_code";
13const HTTP_STATUS_CODE: &str = "http.status_code";
14
15fn matcher_from_rule(rule: &str) -> Option<GlobMatcher> {
16    (rule != NO_RULE).then(|| GlobMatcher::new(rule))
17}
18
19/// Represents a sampling rule with criteria for matching spans
20#[derive(Clone, Debug)]
21pub struct SamplingRule {
22    /// The sample rate to apply when this rule matches (0.0-1.0)
23    pub(crate) sample_rate: f64,
24
25    /// Where this rule comes from (customer, dynamic, default)
26    pub(crate) provenance: String,
27
28    /// Internal rate sampler used when this rule matches
29    rate_sampler: RateSampler,
30
31    /// Glob matchers for pattern matching
32    pub(crate) name_matcher: Option<GlobMatcher>,
33    pub(crate) service_matcher: Option<GlobMatcher>,
34    pub(crate) resource_matcher: Option<GlobMatcher>,
35    pub(crate) tag_matchers: HashMap<String, GlobMatcher>,
36}
37
38impl SamplingRule {
39    /// Converts a vector of SamplingRuleConfig into SamplingRule objects
40    /// Centralizes the conversion logic
41    pub fn from_configs(configs: Vec<SamplingRuleConfig>) -> Vec<Self> {
42        configs
43            .into_iter()
44            .map(|config| {
45                Self::new(
46                    config.sample_rate,
47                    config.service,
48                    config.name,
49                    config.resource,
50                    Some(config.tags),
51                    Some(config.provenance),
52                )
53            })
54            .collect()
55    }
56
57    /// Creates a new sampling rule
58    pub fn new(
59        sample_rate: f64,
60        service: Option<String>,
61        name: Option<String>,
62        resource: Option<String>,
63        tags: Option<HashMap<String, String>>,
64        provenance: Option<String>,
65    ) -> Self {
66        // Create glob matchers for the patterns
67        let name_matcher = name.as_deref().and_then(matcher_from_rule);
68        let service_matcher = service.as_deref().and_then(matcher_from_rule);
69        let resource_matcher = resource.as_deref().and_then(matcher_from_rule);
70
71        // Create matchers for tag values. `tags` is consumed here so no clone is needed.
72        let tag_map = tags.unwrap_or_default();
73        let mut tag_matchers = HashMap::with_capacity(tag_map.len());
74        for (key, value) in tag_map {
75            if let Some(matcher) = matcher_from_rule(&value) {
76                tag_matchers.insert(key, matcher);
77            }
78        }
79
80        SamplingRule {
81            sample_rate,
82            provenance: provenance.unwrap_or_else(|| "default".to_string()),
83            rate_sampler: RateSampler::new(sample_rate),
84            name_matcher,
85            service_matcher,
86            resource_matcher,
87            tag_matchers,
88        }
89    }
90
91    /// Checks if this rule matches the given span's attributes and name
92    /// The name is derived from the attributes and span kind
93    pub(crate) fn matches(&self, span: &impl SpanProperties) -> bool {
94        // Get the operation name from the span
95        let name = span.operation_name();
96
97        // Check name using glob matcher if specified
98        if let Some(ref matcher) = self.name_matcher {
99            if !matcher.matches(name.as_ref()) {
100                return false;
101            }
102        }
103
104        // Check service if specified using glob matcher
105        if let Some(ref matcher) = self.service_matcher {
106            // Get service from the span
107            let service = span.service();
108
109            // Match against the service
110            if !matcher.matches(&service) {
111                return false;
112            }
113        }
114
115        // Get the resource string for matching
116        let resource_str = span.resource();
117
118        // Check resource if specified using glob matcher
119        if let Some(ref matcher) = self.resource_matcher {
120            // Use the resource from the span
121            if !matcher.matches(resource_str.as_ref()) {
122                return false;
123            }
124        }
125
126        // Check all tags using glob matchers
127        for (key, matcher) in &self.tag_matchers {
128            let rule_tag_key_str = key.as_str();
129
130            // Special handling for rules defined with "http.status_code" or
131            // "http.response.status_code"
132            if rule_tag_key_str == HTTP_STATUS_CODE || rule_tag_key_str == HTTP_RESPONSE_STATUS_CODE
133            {
134                match self.match_http_status_code_rule(matcher, span) {
135                    Some(true) => continue,             // Status code matched
136                    Some(false) | None => return false, // Status code didn't match or wasn't found
137                }
138            } else {
139                // Logic for other tags:
140                // First, try to match directly with the provided tag key
141                let direct_match = span
142                    .attributes()
143                    .find(|attr| attr.key() == rule_tag_key_str)
144                    .and_then(|attr| self.match_attribute_value(attr.value(), matcher));
145
146                if direct_match.unwrap_or(false) {
147                    continue;
148                }
149
150                // If no direct match, try to find the corresponding OpenTelemetry attribute that
151                // maps to the Datadog tag key This handles cases where the rule key
152                // is a Datadog key (e.g., "http.method") and the attribute is an
153                // OTel key (e.g., "http.request.method")
154                if rule_tag_key_str.starts_with("http.") {
155                    let tag_match = span.attributes().any(|attr| {
156                        if let Some(alternate_key) = span.get_alternate_key(attr.key()) {
157                            if alternate_key == rule_tag_key_str {
158                                return self
159                                    .match_attribute_value(attr.value(), matcher)
160                                    .unwrap_or(false);
161                            }
162                        }
163                        false
164                    });
165
166                    if !tag_match {
167                        return false; // Mapped attribute not found or did not match
168                    }
169                    // If tag_match is true, loop continues to next rule_tag_key.
170                } else {
171                    // For non-HTTP attributes, if we don't have a direct match, the rule doesn't
172                    // match
173                    return false;
174                }
175            }
176        }
177
178        true
179    }
180
181    /// Helper method to specifically match a rule against an HTTP status code extracted from
182    /// attributes. Returns Some(true) if status code found and matches, Some(false) if found
183    /// but not matched, None if not found.
184    fn match_http_status_code_rule(
185        &self,
186        matcher: &GlobMatcher,
187        span: &impl SpanProperties,
188    ) -> Option<bool> {
189        span.status_code().and_then(|status_code| {
190            let status_value = ValueI64(i64::from(status_code));
191            self.match_attribute_value(&status_value, matcher)
192        })
193    }
194
195    // Helper method to match attribute values considering different value types
196    fn match_attribute_value(&self, value: &impl ValueLike, matcher: &GlobMatcher) -> Option<bool> {
197        // Floating point values are handled with special rules
198        if let Some(float_val) = value.as_float() {
199            // A float is treated as an integer iff it has no fractional part *and* it is
200            // finite. `fract()` is robust for values outside the i64 range, unlike
201            // `(float_val as i64) as f64` which silently saturates / produces garbage.
202            let is_integer = float_val.is_finite() && float_val.fract() == 0.0;
203
204            // For non-integer floats, only match if it's a wildcard pattern
205            if !is_integer {
206                // All '*' pattern returns true, any other pattern returns false
207                return Some(matcher.pattern().chars().all(|c| c == '*'));
208            }
209
210            // For integer floats, convert to string for matching
211            return Some(matcher.matches(&float_val.to_string()));
212        }
213
214        // For non-float values, use normal matching
215        value
216            .as_str()
217            .map(|string_value| matcher.matches(&string_value))
218    }
219
220    /// Samples a trace ID using this rule's sample rate
221    pub fn sample(&self, trace_id: &impl TraceIdLike) -> bool {
222        // Delegate to the internal rate sampler's new sample method
223        self.rate_sampler.sample(trace_id)
224    }
225}
226
227/// Represents a priority for sampling rules.
228///
229/// TODO: this enum is currently only exercised by tests and `From<&str>`. It will become
230/// actively used once Remote Configuration drives `SamplingRule::provenance` (which today
231/// is a `String`). The `dead_code` allow can go away once that wiring lands.
232#[allow(dead_code)]
233#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
234pub(crate) enum RuleProvenance {
235    Customer = 0,
236    Dynamic = 1,
237    Default = 2,
238}
239
240impl From<&str> for RuleProvenance {
241    fn from(s: &str) -> Self {
242        match s {
243            "customer" => RuleProvenance::Customer,
244            "dynamic" => RuleProvenance::Dynamic,
245            _ => RuleProvenance::Default,
246        }
247    }
248}
249
250/// Helper struct for representing i64 values as ValueLike
251struct ValueI64(i64);
252
253impl ValueLike for ValueI64 {
254    fn as_float(&self) -> Option<f64> {
255        Some(self.0 as f64)
256    }
257
258    fn as_str(&self) -> Option<std::borrow::Cow<'_, str>> {
259        Some(std::borrow::Cow::Owned(self.0.to_string()))
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::sampling_rule_config::SamplingRuleConfig;
267    use std::borrow::Cow;
268
269    // Minimal SpanProperties impl for unit testing sampling_rule logic.
270    struct TestSpan {
271        name: &'static str,
272        service: &'static str,
273        resource: &'static str,
274        status_code: Option<u32>,
275        // (key, value_str, is_metric) — is_metric=true gives a float value
276        attrs: Vec<TestAttr>,
277        // alternate key mapping: (stored_key, alternate_dd_key)
278        alternates: Vec<(&'static str, &'static str)>,
279    }
280
281    struct TestAttr {
282        key: &'static str,
283        value: TestValue,
284    }
285
286    struct TestValue {
287        value: &'static str,
288        is_metric: bool,
289    }
290
291    impl crate::types::ValueLike for TestValue {
292        fn as_float(&self) -> Option<f64> {
293            if self.is_metric {
294                self.value.parse().ok()
295            } else {
296                None
297            }
298        }
299        fn as_str(&self) -> Option<Cow<'_, str>> {
300            Some(Cow::Borrowed(self.value))
301        }
302    }
303
304    impl crate::types::AttributeLike for TestAttr {
305        type Value = TestValue;
306        fn key(&self) -> &str {
307            self.key
308        }
309        fn value(&self) -> &TestValue {
310            &self.value
311        }
312    }
313
314    impl crate::types::SpanProperties for TestSpan {
315        type Attribute<'a>
316            = &'a TestAttr
317        where
318            Self: 'a;
319
320        fn operation_name(&self) -> Cow<'_, str> {
321            Cow::Borrowed(self.name)
322        }
323        fn service(&self) -> Cow<'_, str> {
324            Cow::Borrowed(self.service)
325        }
326        fn env(&self) -> Cow<'_, str> {
327            Cow::Borrowed("")
328        }
329        fn resource(&self) -> Cow<'_, str> {
330            Cow::Borrowed(self.resource)
331        }
332        fn status_code(&self) -> Option<u32> {
333            self.status_code
334        }
335        fn attributes(&self) -> impl Iterator<Item = &TestAttr> + '_ {
336            self.attrs.iter()
337        }
338        fn get_alternate_key<'b>(&self, key: &'b str) -> Option<Cow<'b, str>> {
339            self.alternates
340                .iter()
341                .find(|(k, _)| *k == key)
342                .map(|(_, alt)| Cow::Borrowed(*alt))
343        }
344    }
345
346    fn make_span(name: &'static str, service: &'static str, resource: &'static str) -> TestSpan {
347        TestSpan {
348            name,
349            service,
350            resource,
351            status_code: None,
352            attrs: vec![],
353            alternates: vec![],
354        }
355    }
356
357    // --- from_configs ---
358
359    #[test]
360    fn test_from_configs_empty() {
361        let rules = SamplingRule::from_configs(vec![]);
362        assert!(rules.is_empty());
363    }
364
365    #[test]
366    fn test_from_configs_single() {
367        let config = SamplingRuleConfig {
368            sample_rate: 0.5,
369            service: Some("svc".into()),
370            name: Some("op.*".into()),
371            resource: None,
372            tags: HashMap::new(),
373            provenance: "customer".into(),
374        };
375        let rules = SamplingRule::from_configs(vec![config]);
376        assert_eq!(rules.len(), 1);
377        assert_eq!(rules[0].sample_rate, 0.5);
378        assert_eq!(rules[0].provenance, "customer");
379    }
380
381    #[test]
382    fn test_from_configs_preserves_provenance() {
383        let configs = vec![
384            SamplingRuleConfig {
385                sample_rate: 1.0,
386                provenance: "customer".into(),
387                ..Default::default()
388            },
389            SamplingRuleConfig {
390                sample_rate: 0.5,
391                provenance: "dynamic".into(),
392                ..Default::default()
393            },
394            SamplingRuleConfig {
395                sample_rate: 0.1,
396                provenance: "default".into(),
397                ..Default::default()
398            },
399        ];
400        let rules = SamplingRule::from_configs(configs);
401        assert_eq!(rules[0].provenance, "customer");
402        assert_eq!(rules[1].provenance, "dynamic");
403        assert_eq!(rules[2].provenance, "default");
404    }
405
406    // --- HTTP status code matching ---
407
408    #[test]
409    fn test_matches_http_status_code_rule_matching() {
410        let rule = SamplingRule::new(
411            1.0,
412            None,
413            None,
414            None,
415            Some(HashMap::from([("http.status_code".into(), "200".into())])),
416            None,
417        );
418        let mut span = make_span("op", "svc", "res");
419        span.status_code = Some(200);
420        assert!(rule.matches(&span));
421    }
422
423    #[test]
424    fn test_matches_http_status_code_rule_not_matching() {
425        let rule = SamplingRule::new(
426            1.0,
427            None,
428            None,
429            None,
430            Some(HashMap::from([("http.status_code".into(), "200".into())])),
431            None,
432        );
433        let mut span = make_span("op", "svc", "res");
434        span.status_code = Some(404);
435        assert!(!rule.matches(&span));
436    }
437
438    #[test]
439    fn test_matches_http_status_code_absent_returns_false() {
440        let rule = SamplingRule::new(
441            1.0,
442            None,
443            None,
444            None,
445            Some(HashMap::from([("http.status_code".into(), "200".into())])),
446            None,
447        );
448        let span = make_span("op", "svc", "res"); // no status_code
449        assert!(!rule.matches(&span));
450    }
451
452    #[test]
453    fn test_matches_http_response_status_code_key() {
454        let rule = SamplingRule::new(
455            1.0,
456            None,
457            None,
458            None,
459            Some(HashMap::from([(
460                "http.response.status_code".into(),
461                "404".into(),
462            )])),
463            None,
464        );
465        let mut span = make_span("op", "svc", "res");
466        span.status_code = Some(404);
467        assert!(rule.matches(&span));
468    }
469
470    #[test]
471    fn test_matches_http_status_code_wildcard() {
472        let rule = SamplingRule::new(
473            1.0,
474            None,
475            None,
476            None,
477            Some(HashMap::from([("http.status_code".into(), "2*".into())])),
478            None,
479        );
480        let mut span = make_span("op", "svc", "res");
481        span.status_code = Some(201);
482        assert!(rule.matches(&span));
483    }
484
485    // --- Alternate key (OTel → DD) matching ---
486
487    #[test]
488    fn test_matches_alternate_key_found() {
489        // Rule uses DD key "http.method"; span stores OTel key "http.request.method"
490        // with alternate mapping back to "http.method"
491        let rule = SamplingRule::new(
492            1.0,
493            None,
494            None,
495            None,
496            Some(HashMap::from([("http.method".into(), "POST".into())])),
497            None,
498        );
499        let mut span = make_span("op", "svc", "res");
500        span.attrs = vec![TestAttr {
501            key: "http.request.method",
502            value: TestValue {
503                value: "POST",
504                is_metric: false,
505            },
506        }];
507        span.alternates = vec![("http.request.method", "http.method")];
508        assert!(rule.matches(&span));
509    }
510
511    #[test]
512    fn test_matches_alternate_key_value_mismatch() {
513        let rule = SamplingRule::new(
514            1.0,
515            None,
516            None,
517            None,
518            Some(HashMap::from([("http.method".into(), "POST".into())])),
519            None,
520        );
521        let mut span = make_span("op", "svc", "res");
522        span.attrs = vec![TestAttr {
523            key: "http.request.method",
524            value: TestValue {
525                value: "GET",
526                is_metric: false,
527            },
528        }];
529        span.alternates = vec![("http.request.method", "http.method")];
530        assert!(!rule.matches(&span));
531    }
532
533    #[test]
534    fn test_matches_non_http_tag_no_alternate_fallback() {
535        // Non-http. keys do NOT fall through to alternate-key scan
536        let rule = SamplingRule::new(
537            1.0,
538            None,
539            None,
540            None,
541            Some(HashMap::from([("custom.tag".into(), "value".into())])),
542            None,
543        );
544        let mut span = make_span("op", "svc", "res");
545        span.attrs = vec![TestAttr {
546            key: "some.other.key",
547            value: TestValue {
548                value: "value",
549                is_metric: false,
550            },
551        }];
552        span.alternates = vec![("some.other.key", "custom.tag")];
553        assert!(!rule.matches(&span));
554    }
555
556    // --- Float attribute matching ---
557
558    #[test]
559    fn test_match_attribute_value_non_integer_float_wildcard_matches() {
560        let rule = SamplingRule::new(
561            1.0,
562            None,
563            None,
564            None,
565            Some(HashMap::from([("score".into(), "*".into())])),
566            None,
567        );
568        let mut span = make_span("op", "svc", "res");
569        span.attrs = vec![TestAttr {
570            key: "score",
571            value: TestValue {
572                value: "3.14",
573                is_metric: true,
574            },
575        }];
576        assert!(rule.matches(&span));
577    }
578
579    #[test]
580    fn test_match_attribute_value_non_integer_float_non_wildcard_no_match() {
581        let rule = SamplingRule::new(
582            1.0,
583            None,
584            None,
585            None,
586            Some(HashMap::from([("score".into(), "3.14".into())])),
587            None,
588        );
589        let mut span = make_span("op", "svc", "res");
590        span.attrs = vec![TestAttr {
591            key: "score",
592            value: TestValue {
593                value: "3.14",
594                is_metric: true,
595            },
596        }];
597        assert!(!rule.matches(&span));
598    }
599
600    #[test]
601    fn test_resource_mismatch_returns_false() {
602        let rule = SamplingRule::new(
603            1.0,
604            None,
605            Some("specific-resource".into()),
606            None,
607            None,
608            None,
609        );
610        let span = make_span("op", "svc", "other-resource");
611        assert!(!rule.matches(&span));
612    }
613
614    // --- RuleProvenance ---
615
616    #[test]
617    fn test_rule_provenance_from_str() {
618        assert_eq!(RuleProvenance::from("customer"), RuleProvenance::Customer);
619        assert_eq!(RuleProvenance::from("dynamic"), RuleProvenance::Dynamic);
620        assert_eq!(RuleProvenance::from("default"), RuleProvenance::Default);
621        assert_eq!(RuleProvenance::from("unknown"), RuleProvenance::Default);
622        assert_eq!(RuleProvenance::from(""), RuleProvenance::Default);
623    }
624
625    #[test]
626    fn test_rule_provenance_ordering() {
627        assert!(RuleProvenance::Customer < RuleProvenance::Dynamic);
628        assert!(RuleProvenance::Dynamic < RuleProvenance::Default);
629    }
630}