Skip to main content

libdd_trace_utils/
trace_filter.rs

1// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3//! Trace-level filter logic for client-side stats (filter_tags, filter_tags_regex,
4//! ignore_resources as published by the agent's /info endpoint).
5use std::borrow::Borrow as _;
6
7use libdd_common::regex_engine::Regex;
8use libdd_trace_normalization::{normalize_utils, normalizer};
9use tracing::{debug, error};
10
11use crate::span::{self, trace_utils::get_root_span_index, TraceData};
12
13trait TagFilter {
14    /// Returns true if the given tag value matches the Filterer.
15    fn matches_tag_value(&self, value: &str) -> bool;
16    // Getter to the key field
17    fn key(&self) -> &str;
18}
19
20#[derive(Debug)]
21struct TagLiteralFilter {
22    key: String,
23    value: Option<String>,
24}
25
26#[derive(Debug)]
27struct TagRegexFilter {
28    key: String,
29    value: Option<Regex>,
30}
31
32/// Applies trace-level filters derived from the agent's `/info` endpoint configuration:
33/// `filter_tags`, `filter_tags_regex`, and `ignore_resources`.
34///
35/// Filtering is evaluated on the root span of each trace.
36#[derive(Debug, Default)]
37pub struct TraceFilterer {
38    reject: Vec<TagLiteralFilter>,
39    reject_regex: Vec<TagRegexFilter>,
40
41    require: Vec<TagLiteralFilter>,
42    require_regex: Vec<TagRegexFilter>,
43
44    ignore_resources: Vec<Regex>,
45}
46
47/// Minimal span interface required by [`TraceFilterer`].
48pub trait Span<'a> {
49    /// Returns the normalized resource value
50    fn resource_normalized(&'a self) -> &'a str;
51    /// Returns the value of the given meta tag, if present.
52    fn get_meta(&'a self, key: &str) -> Option<&'a str>;
53}
54
55impl TagFilter for TagLiteralFilter {
56    fn matches_tag_value(&self, value: &str) -> bool {
57        match &self.value {
58            None => true, // No value requirement => Any value is a match
59            Some(required_value) => value == required_value,
60        }
61    }
62
63    fn key(&self) -> &str {
64        &self.key
65    }
66}
67
68impl TagFilter for TagRegexFilter {
69    fn matches_tag_value(&self, value: &str) -> bool {
70        match &self.value {
71            None => true, // No value requirement => Any value is a match
72            Some(pattern) => pattern.is_match(value),
73        }
74    }
75
76    fn key(&self) -> &str {
77        &self.key
78    }
79}
80
81impl<'a, T: TraceData> Span<'a> for span::v04::Span<T> {
82    fn resource_normalized(&'a self) -> &'a str {
83        // Normalization
84        let span_resource = self.resource.borrow();
85        if span_resource.is_empty() {
86            let span_name = self.name.borrow();
87            debug!(
88                ?span_name,
89                "Trace filter: filtering on name because resource is empty"
90            );
91            span_name
92        } else {
93            span_resource
94        }
95    }
96
97    fn get_meta(&'a self, key: &str) -> Option<&'a str> {
98        self.meta.get(key).map(|v| v.borrow())
99    }
100}
101
102impl TraceFilterer {
103    fn compile_literal_filters(filters: &[String]) -> Vec<TagLiteralFilter> {
104        let mut tag_regex_filters = Vec::new();
105        for filter in filters {
106            let (key, value) = match filter.split_once(":") {
107                Some((key, value)) if !value.trim().is_empty() => {
108                    (key.trim(), Some(value.trim().to_owned()))
109                }
110                _ => (filter.trim(), None),
111            };
112            if key.is_empty() {
113                error!(
114                    ?filter,
115                    "Invalid tag filter with empty key value, skipping it"
116                );
117                continue;
118            }
119
120            tag_regex_filters.push(TagLiteralFilter {
121                key: key.to_owned(),
122                value,
123            });
124        }
125
126        tag_regex_filters
127    }
128
129    fn compile_regex_filters(filters: &[String]) -> Vec<TagRegexFilter> {
130        let mut tag_regex_filters = Vec::new();
131        for filter in filters {
132            let (key, value) = match filter.split_once(":") {
133                Some((key, value)) if !value.trim().is_empty() => (key.trim(), Some(value.trim())),
134                _ => (filter.trim(), None),
135            };
136            if key.is_empty() {
137                error!(
138                    ?filter,
139                    "Invalid tag filter with empty key value, skipping it"
140                );
141                continue;
142            }
143
144            let value = match value {
145                Some(value) => match Regex::new(value) {
146                    Ok(regex) => Some(regex),
147                    Err(err) => {
148                        error!(
149                            ?filter,
150                            ?err,
151                            "Invalid regex pattern in tag filter's value, skipping it"
152                        );
153                        continue;
154                    }
155                },
156                None => None,
157            };
158
159            tag_regex_filters.push(TagRegexFilter {
160                key: key.to_owned(),
161                value,
162            });
163        }
164
165        tag_regex_filters
166    }
167
168    fn compile_resource_filters(ignore_resources: &[String]) -> Vec<Regex> {
169        ignore_resources
170            .iter()
171            .filter_map(|regex| {
172                Regex::new(regex)
173                    .inspect_err(|err| {
174                        error!(
175                            ?regex,
176                            ?err,
177                            "Invalid regex pattern in ignore resources filter, skipping it"
178                        )
179                    })
180                    .ok()
181            })
182            .collect()
183    }
184
185    /// Creates a new filterer from the agent's `/info` configuration fields.
186    ///
187    /// Invalid regex patterns are logged and skipped rather than causing an error.
188    pub fn new(
189        filter_tags_require: &[String],
190        filter_tags_reject: &[String],
191        filter_tags_regex_require: &[String],
192        filter_tags_regex_reject: &[String],
193        ignore_resources: &[String],
194    ) -> Self {
195        let require_regex = Self::compile_regex_filters(filter_tags_regex_require);
196        let reject_regex = Self::compile_regex_filters(filter_tags_regex_reject);
197        let require = Self::compile_literal_filters(filter_tags_require);
198        let reject = Self::compile_literal_filters(filter_tags_reject);
199        let ignore_resources = Self::compile_resource_filters(ignore_resources);
200
201        Self {
202            reject,
203            require,
204            reject_regex,
205            require_regex,
206            ignore_resources,
207        }
208    }
209    /// Creates a no-op filterer that keeps all traces.
210    pub fn with_empty_conf() -> Self {
211        Self::default()
212    }
213
214    /// Removes traces that fail filter checks in-place. Returns the number of traces dropped.
215    pub fn filter_traces(&self, traces: &mut Vec<Vec<span::v04::Span<impl TraceData>>>) -> usize {
216        let traces_count_before = traces.len();
217        traces.retain(|trace| {
218            let Ok(root_span_index) = get_root_span_index(trace) else {
219                return true;
220            };
221            let should_drop = self.should_drop(&trace[root_span_index]);
222            if should_drop {
223                debug!("Trace rejected as it fails to meet tag requirements. root: %v");
224            }
225            !should_drop
226        });
227        let traces_count_after = traces.len();
228
229        traces_count_before - traces_count_after
230    }
231
232    /// Checks if the trace with root span `root_span` should be dropped based on filter
233    /// configuration.
234    ///
235    /// Applies a subset of trace normalization logic from `libdd-trace-normalization` before
236    /// checking.
237    // 1. Resource filtering: If the root span's resource name matches any pattern in
238    //    ignore_resources, reject the trace.
239    // 2. Reject filtering: If any tag on the root span matches filters in filter_tags.reject or
240    //    filter_tags_regex.reject, reject the trace.
241    // 3. Require filtering: If filter_tags.require or filter_tags_regex.require contain any
242    //    filters, all of them must match tags on the root span. If any required filter doesn't
243    //    match, reject the trace.
244    pub fn should_drop<'a>(&self, root_span: &'a impl Span<'a>) -> bool {
245        if !self.ignore_resources.is_empty() {
246            let span_resource = root_span.resource_normalized();
247
248            if self
249                .ignore_resources
250                .iter()
251                .any(|resource_pattern| resource_pattern.is_match(span_resource))
252            {
253                return true;
254            }
255        }
256
257        if self
258            .reject
259            .iter()
260            .any(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
261        {
262            return true;
263        }
264
265        if self
266            .reject_regex
267            .iter()
268            .any(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
269        {
270            return true;
271        }
272
273        if !self
274            .require
275            .iter()
276            .all(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
277        {
278            return true;
279        }
280
281        if !self
282            .require_regex
283            .iter()
284            .all(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
285        {
286            return true;
287        }
288
289        false
290    }
291
292    fn check_tag_filter_with_normalization<'a>(
293        filter: &impl TagFilter,
294        root_span: &'a impl Span<'a>,
295    ) -> bool {
296        let Some(value) = root_span.get_meta(filter.key()) else {
297            return false;
298        };
299        match filter.key() {
300            "env" => {
301                let normalized_value = normalize_utils::normalize_tag_cloned(value);
302                filter.matches_tag_value(&normalized_value)
303            }
304            "http.status_code" => {
305                if !normalizer::is_valid_http_status_code(value) {
306                    debug!(?value,"trace filter on http.status_code ignored because root span's `http.status_code` is invalid");
307                    return false;
308                }
309                filter.matches_tag_value(value)
310            }
311            _ => filter.matches_tag_value(value),
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::TraceFilterer;
319    use crate::span::v04::{SpanBytes, VecMap};
320    // ---- helpers ----
321
322    fn span_with(resource: &'static str, meta: &[(&'static str, &'static str)]) -> SpanBytes {
323        SpanBytes {
324            service: "svc".into(),
325            name: "op".into(),
326            resource: resource.into(),
327            span_id: 1,
328            trace_id: 1,
329            parent_id: 0,
330            meta: meta
331                .iter()
332                .map(|(k, v)| ((*k).into(), (*v).into()))
333                .collect::<VecMap<_, _>>(),
334            ..Default::default()
335        }
336    }
337
338    fn one_trace(s: SpanBytes) -> Vec<Vec<SpanBytes>> {
339        vec![vec![s]]
340    }
341
342    fn map_to_owned(values: &[&str]) -> Vec<String> {
343        values.iter().map(|&s| s.to_owned()).collect()
344    }
345
346    fn require_str(tags: &[&str]) -> TraceFilterer {
347        TraceFilterer::new(&map_to_owned(tags), &[], &[], &[], &[])
348    }
349
350    fn reject_str(tags: &[&str]) -> TraceFilterer {
351        TraceFilterer::new(&[], &map_to_owned(tags), &[], &[], &[])
352    }
353
354    fn require_regex(tags: &[&str]) -> TraceFilterer {
355        TraceFilterer::new(&[], &[], &map_to_owned(tags), &[], &[])
356    }
357
358    fn reject_regex(tags: &[&str]) -> TraceFilterer {
359        TraceFilterer::new(&[], &[], &[], &map_to_owned(tags), &[])
360    }
361
362    fn ignore_resources(patterns: &[&str]) -> TraceFilterer {
363        TraceFilterer::new(&[], &[], &[], &[], &map_to_owned(patterns))
364    }
365
366    // ---- reject (TagStringFilter) ----
367
368    #[test]
369    fn reject_string_exact_match_drops() {
370        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
371        reject_str(&["env:prod"]).filter_traces(&mut traces);
372        assert!(traces.is_empty());
373    }
374
375    #[test]
376    fn reject_string_wrong_value_keeps() {
377        let mut traces = one_trace(span_with("r", &[("env", "staging")]));
378        reject_str(&["env:prod"]).filter_traces(&mut traces);
379        assert_eq!(traces.len(), 1);
380    }
381
382    #[test]
383    fn reject_string_missing_tag_keeps() {
384        let mut traces = one_trace(span_with("r", &[]));
385        reject_str(&["env:prod"]).filter_traces(&mut traces);
386        assert_eq!(traces.len(), 1);
387    }
388
389    #[test]
390    fn reject_string_key_only_matches_any_value() {
391        // A key-only filter (no `:value` part) matches regardless of the tag's value.
392        let mut traces = one_trace(span_with("r", &[("env", "anything")]));
393        reject_str(&["env"]).filter_traces(&mut traces);
394        assert!(traces.is_empty());
395    }
396
397    // ---- reject_regex (TagRegexFilter – literal key, regex value) ----
398
399    #[test]
400    fn reject_regex_value_match_drops() {
401        let mut traces = one_trace(span_with("r", &[("env", "production")]));
402        reject_regex(&["env:prod.*"]).filter_traces(&mut traces);
403        assert!(traces.is_empty());
404    }
405
406    #[cfg_attr(miri, ignore)] // regex compilation is prohibitively slow under Miri
407    #[test]
408    fn reject_regex_value_no_match_keeps() {
409        let mut traces = one_trace(span_with("r", &[("env", "staging")]));
410        reject_regex(&["env:prod.*"]).filter_traces(&mut traces);
411        assert_eq!(traces.len(), 1);
412    }
413
414    // ---- reject_key_regex ----
415    // Checks that it's not implemented
416
417    #[test]
418    fn reject_key_regex_key_and_value_match_drops() {
419        let mut traces = one_trace(span_with("r", &[("error", "timeout")]));
420        reject_regex(&["err.*:timeout"]).filter_traces(&mut traces);
421        // Regex keys are not implemented so it doesn't match
422        assert!(!traces.is_empty());
423    }
424
425    #[test]
426    fn reject_key_regex_wrong_value_keeps() {
427        let mut traces = one_trace(span_with("r", &[("error", "network")]));
428        reject_regex(&["err.*:timeout"]).filter_traces(&mut traces);
429        assert_eq!(traces.len(), 1);
430    }
431
432    #[test]
433    fn reject_key_regex_missing_key_keeps() {
434        let mut traces = one_trace(span_with("r", &[]));
435        reject_regex(&["err.*:timeout"]).filter_traces(&mut traces);
436        assert_eq!(traces.len(), 1);
437    }
438
439    // ---- require (TagStringFilter) ----
440
441    #[test]
442    fn require_string_present_and_matching_keeps() {
443        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
444        require_str(&["env:prod"]).filter_traces(&mut traces);
445        assert_eq!(traces.len(), 1);
446    }
447
448    #[test]
449    fn require_string_missing_tag_drops() {
450        let mut traces = one_trace(span_with("r", &[]));
451        require_str(&["env:prod"]).filter_traces(&mut traces);
452        assert!(traces.is_empty());
453    }
454
455    #[test]
456    fn require_string_wrong_value_drops() {
457        let mut traces = one_trace(span_with("r", &[("env", "staging")]));
458        require_str(&["env:prod"]).filter_traces(&mut traces);
459        assert!(traces.is_empty());
460    }
461
462    // ---- require_regex (TagRegexFilter – literal key, regex value) ----
463
464    #[cfg_attr(miri, ignore)] // regex compilation is prohibitively slow under Miri
465    #[test]
466    fn require_regex_value_match_keeps() {
467        let mut traces = one_trace(span_with("r", &[("env", "production")]));
468        require_regex(&["env:prod.*"]).filter_traces(&mut traces);
469        assert_eq!(traces.len(), 1);
470    }
471
472    #[test]
473    fn require_regex_missing_drops() {
474        let mut traces = one_trace(span_with("r", &[]));
475        require_regex(&["env:prod.*"]).filter_traces(&mut traces);
476        assert!(traces.is_empty());
477    }
478
479    // ---- require_key_regex  ----
480    // (Checks that it's not implemented)
481
482    #[test]
483    fn require_key_regex_key_exists_keeps() {
484        let mut traces = one_trace(span_with("r", &[("error", "any")]));
485        require_regex(&["err.*"]).filter_traces(&mut traces);
486        // Regex keys are not implemented so it doesn't match
487        assert!(traces.is_empty());
488    }
489
490    #[test]
491    fn require_key_regex_missing_key_drops() {
492        let mut traces = one_trace(span_with("r", &[]));
493        require_regex(&["err.*"]).filter_traces(&mut traces);
494        assert!(traces.is_empty());
495    }
496
497    // ---- ignore_resources ----
498
499    #[test]
500    fn ignore_resources_match_drops() {
501        let mut traces = one_trace(span_with("GET /health", &[]));
502        ignore_resources(&["GET /health"]).filter_traces(&mut traces);
503        assert!(traces.is_empty());
504    }
505
506    #[test]
507    fn ignore_resources_no_match_keeps() {
508        let mut traces = one_trace(span_with("POST /data", &[]));
509        ignore_resources(&["GET /health"]).filter_traces(&mut traces);
510        assert_eq!(traces.len(), 1);
511    }
512
513    #[test]
514    fn ignore_resources_empty_resource_falls_back_to_name() {
515        // When resource is empty the span's name field is used for matching.
516        // The helper sets name = "op", so ignore_resources("op") must drop it.
517        let mut traces = one_trace(span_with("", &[]));
518        ignore_resources(&["op"]).filter_traces(&mut traces);
519        assert!(traces.is_empty());
520    }
521
522    // ---- env tag normalization ----
523
524    #[test]
525    fn env_normalization_reject_matches_after_lowercase() {
526        // normalize_tag_cloned("PROD") == "prod"; the reject filter "env:prod" must fire.
527        let mut traces = one_trace(span_with("r", &[("env", "PROD")]));
528        reject_str(&["env:prod"]).filter_traces(&mut traces);
529        assert!(
530            traces.is_empty(),
531            "env value should be normalized before matching"
532        );
533    }
534
535    #[test]
536    fn env_normalization_require_matches_normalized_value() {
537        // normalize_tag_cloned("Prod Env") == "prod_env" (uppercase + space → underscore).
538        let mut traces = one_trace(span_with("r", &[("env", "Prod Env")]));
539        require_str(&["env:prod_env"]).filter_traces(&mut traces);
540        assert_eq!(
541            traces.len(),
542            1,
543            "normalized env should satisfy the require filter"
544        );
545    }
546
547    // ---- http.status_code special handling ----
548
549    #[test]
550    fn http_status_code_invalid_value_skips_reject_filter() {
551        // is_valid_http_status_code("abc") == false → check_tag_filter returns false
552        // → reject never fires → trace kept even though the raw value equals the filter.
553        let mut traces = one_trace(span_with("r", &[("http.status_code", "abc")]));
554        reject_str(&["http.status_code:abc"]).filter_traces(&mut traces);
555        assert_eq!(
556            traces.len(),
557            1,
558            "invalid status code should not trigger the filter"
559        );
560    }
561
562    #[test]
563    fn http_status_code_valid_value_triggers_reject_filter() {
564        let mut traces = one_trace(span_with("r", &[("http.status_code", "500")]));
565        reject_str(&["http.status_code:500"]).filter_traces(&mut traces);
566        assert!(traces.is_empty());
567    }
568
569    // ---- edge / misc ----
570
571    #[test]
572    fn multiple_traces_partial_rejection() {
573        let f = reject_str(&["env:prod"]);
574        let mut traces = vec![
575            vec![span_with("r", &[("env", "prod")])],    // dropped
576            vec![span_with("r", &[("env", "staging")])], // kept
577        ];
578        f.filter_traces(&mut traces);
579        assert_eq!(traces.len(), 1);
580    }
581
582    #[test]
583    fn no_filters_keeps_all_traces() {
584        let f = TraceFilterer::new(&[], &[], &[], &[], &[]);
585        let mut traces = vec![
586            vec![span_with("r1", &[])],
587            vec![span_with("r2", &[("env", "prod")])],
588        ];
589        f.filter_traces(&mut traces);
590        assert_eq!(traces.len(), 2);
591    }
592
593    #[test]
594    fn invalid_regex_in_filter_is_skipped_gracefully() {
595        // A bad regex pattern is silently discarded; no panic, trace is kept.
596        let f = reject_regex(&["env:[invalid"]);
597        let mut traces = one_trace(span_with("r", &[("env", "anything")]));
598        f.filter_traces(&mut traces);
599        assert_eq!(traces.len(), 1);
600    }
601
602    // ---- key/value trimming ----
603
604    #[test]
605    fn literal_reject_spaces_around_colon_drops() {
606        // " env : prod " → key="env", value="prod"
607        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
608        reject_str(&[" env : prod "]).filter_traces(&mut traces);
609        assert!(traces.is_empty());
610    }
611
612    #[test]
613    fn literal_require_spaces_around_colon_keeps() {
614        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
615        require_str(&[" env : prod "]).filter_traces(&mut traces);
616        assert_eq!(traces.len(), 1);
617    }
618
619    #[test]
620    fn literal_reject_key_only_with_spaces_drops_any_value() {
621        // " env " (no colon) → key="env", no value requirement
622        let mut traces = one_trace(span_with("r", &[("env", "anything")]));
623        reject_str(&[" env "]).filter_traces(&mut traces);
624        assert!(traces.is_empty());
625    }
626
627    #[test]
628    fn literal_reject_empty_key_is_skipped_keeps() {
629        // ":prod" → key="" → filter skipped → trace kept
630        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
631        reject_str(&[":prod"]).filter_traces(&mut traces);
632        assert_eq!(traces.len(), 1);
633    }
634
635    #[test]
636    fn literal_require_empty_key_is_skipped_keeps() {
637        // ":prod" → filter skipped → require list empty → vacuous all() → trace kept
638        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
639        require_str(&[":prod"]).filter_traces(&mut traces);
640        assert_eq!(traces.len(), 1);
641    }
642
643    #[test]
644    fn regex_reject_spaces_around_colon_drops() {
645        // " env : prod.* " → key="env", regex="prod.*"
646        let mut traces = one_trace(span_with("r", &[("env", "production")]));
647        reject_regex(&[" env : prod.* "]).filter_traces(&mut traces);
648        assert!(traces.is_empty());
649    }
650
651    #[cfg_attr(miri, ignore)] // regex compilation is prohibitively slow under Miri
652    #[test]
653    fn regex_require_spaces_around_colon_keeps() {
654        let mut traces = one_trace(span_with("r", &[("env", "production")]));
655        require_regex(&[" env : prod.* "]).filter_traces(&mut traces);
656        assert_eq!(traces.len(), 1);
657    }
658
659    #[test]
660    fn regex_reject_empty_key_is_skipped_keeps() {
661        // ":prod.*" → key="" → filter skipped → trace kept
662        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
663        reject_regex(&[":prod.*"]).filter_traces(&mut traces);
664        assert_eq!(traces.len(), 1);
665    }
666}