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::v1::{AttributeValue, SpanKind, TraceChunk};
12use crate::span::vec_map::VecMap;
13use crate::span::{self, trace_utils::get_root_span_index, trace_utils_v1, TraceData};
14
15trait TagFilter {
16    /// Returns true if the given tag value matches the Filterer.
17    fn matches_tag_value(&self, value: &str) -> bool;
18    // Getter to the key field
19    fn key(&self) -> &str;
20}
21
22#[derive(Debug)]
23struct TagLiteralFilter {
24    key: String,
25    value: Option<String>,
26}
27
28#[derive(Debug)]
29struct TagRegexFilter {
30    key: String,
31    value: Option<Regex>,
32}
33
34/// Applies trace-level filters derived from the agent's `/info` endpoint configuration:
35/// `filter_tags`, `filter_tags_regex`, and `ignore_resources`.
36///
37/// Filtering is evaluated on the root span of each trace.
38#[derive(Debug, Default)]
39pub struct TraceFilterer {
40    reject: Vec<TagLiteralFilter>,
41    reject_regex: Vec<TagRegexFilter>,
42
43    require: Vec<TagLiteralFilter>,
44    require_regex: Vec<TagRegexFilter>,
45
46    ignore_resources: Vec<Regex>,
47}
48
49/// Minimal span interface required by [`TraceFilterer`].
50pub trait Span<'a> {
51    /// Returns the normalized resource value
52    fn resource_normalized(&'a self) -> &'a str;
53    /// Returns the value of the given meta tag, if present.
54    fn get_meta(&'a self, key: &str) -> Option<&'a str>;
55}
56
57impl TagFilter for TagLiteralFilter {
58    fn matches_tag_value(&self, value: &str) -> bool {
59        match &self.value {
60            None => true, // No value requirement => Any value is a match
61            Some(required_value) => value == required_value,
62        }
63    }
64
65    fn key(&self) -> &str {
66        &self.key
67    }
68}
69
70impl TagFilter for TagRegexFilter {
71    fn matches_tag_value(&self, value: &str) -> bool {
72        match &self.value {
73            None => true, // No value requirement => Any value is a match
74            Some(pattern) => pattern.is_match(value),
75        }
76    }
77
78    fn key(&self) -> &str {
79        &self.key
80    }
81}
82
83impl<'a, T: TraceData> Span<'a> for span::v04::Span<T> {
84    fn resource_normalized(&'a self) -> &'a str {
85        // Normalization
86        let span_resource = self.resource.borrow();
87        if span_resource.is_empty() {
88            let span_name = self.name.borrow();
89            debug!(
90                ?span_name,
91                "Trace filter: filtering on name because resource is empty"
92            );
93            span_name
94        } else {
95            span_resource
96        }
97    }
98
99    fn get_meta(&'a self, key: &str) -> Option<&'a str> {
100        self.meta.get(key).map(|v| v.borrow())
101    }
102}
103
104impl<'a, T: TraceData> Span<'a> for span::v1::Span<T> {
105    fn resource_normalized(&'a self) -> &'a str {
106        // Normalization
107        let span_resource = self.resource.borrow();
108        if span_resource.is_empty() {
109            let span_name = self.name.borrow();
110            debug!(
111                ?span_name,
112                "Trace filter: filtering on name because resource is empty"
113            );
114            span_name
115        } else {
116            span_resource
117        }
118    }
119
120    fn get_meta(&'a self, key: &str) -> Option<&'a str> {
121        match self.attributes.get(key) {
122            Some(AttributeValue::String(s)) => Some(s.borrow()),
123            // `env`, `version`, `component`, and `span.kind` are "promoted" to dedicated span
124            // fields rather than stored in `attributes` (see the V1 downgrade encoder). Empty
125            // text / the default `Internal` kind are treated as "unset" here.
126            _ => match key {
127                "env" if !self.env.borrow().is_empty() => Some(self.env.borrow()),
128                "version" if !self.version.borrow().is_empty() => Some(self.version.borrow()),
129                "component" if !self.component.borrow().is_empty() => Some(self.component.borrow()),
130                "span.kind" if self.span_kind != SpanKind::Internal => {
131                    Some(self.span_kind.as_meta_str())
132                }
133                _ => None,
134            },
135        }
136    }
137}
138
139/// Wraps a V1 root span together with its enclosing chunk's attributes.
140///
141/// `TraceChunk.attributes` holds values common to every span in the chunk, so a tag lookup on
142/// the root span falls back to the chunk's attributes whenever the span doesn't have its own
143/// value for a given key.
144struct ChunkSpanView<'a, T: TraceData> {
145    span: &'a span::v1::Span<T>,
146    chunk_attributes: &'a VecMap<T::Text, AttributeValue<T>>,
147}
148
149impl<'a, T: TraceData> Span<'a> for ChunkSpanView<'a, T> {
150    fn resource_normalized(&'a self) -> &'a str {
151        self.span.resource_normalized()
152    }
153
154    fn get_meta(&'a self, key: &str) -> Option<&'a str> {
155        self.span
156            .get_meta(key)
157            .or_else(|| match self.chunk_attributes.get(key) {
158                Some(AttributeValue::String(s)) => Some(s.borrow()),
159                _ => None,
160            })
161    }
162}
163
164impl TraceFilterer {
165    fn compile_literal_filters(filters: &[String]) -> Vec<TagLiteralFilter> {
166        let mut tag_regex_filters = Vec::new();
167        for filter in filters {
168            let (key, value) = match filter.split_once(":") {
169                Some((key, value)) if !value.trim().is_empty() => {
170                    (key.trim(), Some(value.trim().to_owned()))
171                }
172                _ => (filter.trim(), None),
173            };
174            if key.is_empty() {
175                error!(
176                    ?filter,
177                    "Invalid tag filter with empty key value, skipping it"
178                );
179                continue;
180            }
181
182            tag_regex_filters.push(TagLiteralFilter {
183                key: key.to_owned(),
184                value,
185            });
186        }
187
188        tag_regex_filters
189    }
190
191    fn compile_regex_filters(filters: &[String]) -> Vec<TagRegexFilter> {
192        let mut tag_regex_filters = Vec::new();
193        for filter in filters {
194            let (key, value) = match filter.split_once(":") {
195                Some((key, value)) if !value.trim().is_empty() => (key.trim(), Some(value.trim())),
196                _ => (filter.trim(), None),
197            };
198            if key.is_empty() {
199                error!(
200                    ?filter,
201                    "Invalid tag filter with empty key value, skipping it"
202                );
203                continue;
204            }
205
206            let value = match value {
207                Some(value) => match Regex::new(value) {
208                    Ok(regex) => Some(regex),
209                    Err(err) => {
210                        error!(
211                            ?filter,
212                            ?err,
213                            "Invalid regex pattern in tag filter's value, skipping it"
214                        );
215                        continue;
216                    }
217                },
218                None => None,
219            };
220
221            tag_regex_filters.push(TagRegexFilter {
222                key: key.to_owned(),
223                value,
224            });
225        }
226
227        tag_regex_filters
228    }
229
230    fn compile_resource_filters(ignore_resources: &[String]) -> Vec<Regex> {
231        ignore_resources
232            .iter()
233            .filter_map(|regex| {
234                Regex::new(regex)
235                    .inspect_err(|err| {
236                        error!(
237                            ?regex,
238                            ?err,
239                            "Invalid regex pattern in ignore resources filter, skipping it"
240                        )
241                    })
242                    .ok()
243            })
244            .collect()
245    }
246
247    /// Creates a new filterer from the agent's `/info` configuration fields.
248    ///
249    /// Invalid regex patterns are logged and skipped rather than causing an error.
250    pub fn new(
251        filter_tags_require: &[String],
252        filter_tags_reject: &[String],
253        filter_tags_regex_require: &[String],
254        filter_tags_regex_reject: &[String],
255        ignore_resources: &[String],
256    ) -> Self {
257        let require_regex = Self::compile_regex_filters(filter_tags_regex_require);
258        let reject_regex = Self::compile_regex_filters(filter_tags_regex_reject);
259        let require = Self::compile_literal_filters(filter_tags_require);
260        let reject = Self::compile_literal_filters(filter_tags_reject);
261        let ignore_resources = Self::compile_resource_filters(ignore_resources);
262
263        Self {
264            reject,
265            require,
266            reject_regex,
267            require_regex,
268            ignore_resources,
269        }
270    }
271    /// Creates a no-op filterer that keeps all traces.
272    pub fn with_empty_conf() -> Self {
273        Self::default()
274    }
275
276    /// Removes traces that fail filter checks in-place. Returns the number of traces dropped.
277    pub fn filter_traces(&self, traces: &mut Vec<Vec<span::v04::Span<impl TraceData>>>) -> usize {
278        let traces_count_before = traces.len();
279        traces.retain(|trace| {
280            let Ok(root_span_index) = get_root_span_index(trace) else {
281                return true;
282            };
283            let should_drop = self.should_drop(&trace[root_span_index]);
284            if should_drop {
285                debug!("Trace rejected as it fails to meet tag requirements. root: %v");
286            }
287            !should_drop
288        });
289        let traces_count_after = traces.len();
290
291        traces_count_before - traces_count_after
292    }
293
294    /// V1 counterpart of [`Self::filter_traces`]: removes chunks that fail filter checks
295    /// in-place. Returns the number of chunks dropped.
296    pub fn filter_traces_v1<T: TraceData>(&self, traces: &mut Vec<TraceChunk<T>>) -> usize {
297        let traces_count_before = traces.len();
298        traces.retain(|chunk| {
299            let Ok(root_span_index) = trace_utils_v1::get_root_span_index(&chunk.spans) else {
300                return true;
301            };
302            let root_span_view = ChunkSpanView {
303                span: &chunk.spans[root_span_index],
304                chunk_attributes: &chunk.attributes,
305            };
306            let should_drop = self.should_drop(&root_span_view);
307            if should_drop {
308                debug!("Trace rejected as it fails to meet tag requirements. root: %v");
309            }
310            !should_drop
311        });
312        let traces_count_after = traces.len();
313
314        traces_count_before - traces_count_after
315    }
316
317    /// Checks if the trace with root span `root_span` should be dropped based on filter
318    /// configuration.
319    ///
320    /// Applies a subset of trace normalization logic from `libdd-trace-normalization` before
321    /// checking.
322    // 1. Resource filtering: If the root span's resource name matches any pattern in
323    //    ignore_resources, reject the trace.
324    // 2. Reject filtering: If any tag on the root span matches filters in filter_tags.reject or
325    //    filter_tags_regex.reject, reject the trace.
326    // 3. Require filtering: If filter_tags.require or filter_tags_regex.require contain any
327    //    filters, all of them must match tags on the root span. If any required filter doesn't
328    //    match, reject the trace.
329    pub fn should_drop<'a>(&self, root_span: &'a impl Span<'a>) -> bool {
330        if !self.ignore_resources.is_empty() {
331            let span_resource = root_span.resource_normalized();
332
333            if self
334                .ignore_resources
335                .iter()
336                .any(|resource_pattern| resource_pattern.is_match(span_resource))
337            {
338                return true;
339            }
340        }
341
342        if self
343            .reject
344            .iter()
345            .any(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
346        {
347            return true;
348        }
349
350        if self
351            .reject_regex
352            .iter()
353            .any(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
354        {
355            return true;
356        }
357
358        if !self
359            .require
360            .iter()
361            .all(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
362        {
363            return true;
364        }
365
366        if !self
367            .require_regex
368            .iter()
369            .all(|filter| Self::check_tag_filter_with_normalization(filter, root_span))
370        {
371            return true;
372        }
373
374        false
375    }
376
377    fn check_tag_filter_with_normalization<'a>(
378        filter: &impl TagFilter,
379        root_span: &'a impl Span<'a>,
380    ) -> bool {
381        let Some(value) = root_span.get_meta(filter.key()) else {
382            return false;
383        };
384        match filter.key() {
385            "env" => {
386                let normalized_value = normalize_utils::normalize_tag_cloned(value);
387                filter.matches_tag_value(&normalized_value)
388            }
389            "http.status_code" => {
390                if !normalizer::is_valid_http_status_code(value) {
391                    debug!(?value,"trace filter on http.status_code ignored because root span's `http.status_code` is invalid");
392                    return false;
393                }
394                filter.matches_tag_value(value)
395            }
396            _ => filter.matches_tag_value(value),
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::TraceFilterer;
404    use crate::span::v04::{SpanBytes, VecMap};
405    use crate::span::v1::{
406        AttributeValue as AttributeValueV1, SpanBytes as SpanBytesV1, TraceChunk,
407    };
408    // ---- helpers ----
409
410    fn span_with(resource: &'static str, meta: &[(&'static str, &'static str)]) -> SpanBytes {
411        SpanBytes {
412            service: "svc".into(),
413            name: "op".into(),
414            resource: resource.into(),
415            span_id: 1,
416            trace_id: 1,
417            parent_id: 0,
418            meta: meta
419                .iter()
420                .map(|(k, v)| ((*k).into(), (*v).into()))
421                .collect::<VecMap<_, _>>(),
422            ..Default::default()
423        }
424    }
425
426    fn one_trace(s: SpanBytes) -> Vec<Vec<SpanBytes>> {
427        vec![vec![s]]
428    }
429
430    fn v1_chunk_with(
431        resource: &'static str,
432        meta: &[(&'static str, &'static str)],
433    ) -> TraceChunk<crate::span::BytesData> {
434        TraceChunk {
435            spans: vec![SpanBytesV1 {
436                service: "svc".into(),
437                name: "op".into(),
438                resource: resource.into(),
439                span_id: 1,
440                parent_id: 0,
441                attributes: meta
442                    .iter()
443                    .map(|(k, v)| ((*k).into(), AttributeValueV1::String((*v).into())))
444                    .collect(),
445                ..Default::default()
446            }],
447            ..Default::default()
448        }
449    }
450
451    fn v1_chunk_with_chunk_attributes(
452        resource: &'static str,
453        chunk_attributes: &[(&'static str, &'static str)],
454    ) -> TraceChunk<crate::span::BytesData> {
455        TraceChunk {
456            spans: vec![SpanBytesV1 {
457                service: "svc".into(),
458                name: "op".into(),
459                resource: resource.into(),
460                span_id: 1,
461                parent_id: 0,
462                ..Default::default()
463            }],
464            attributes: chunk_attributes
465                .iter()
466                .map(|(k, v)| ((*k).into(), AttributeValueV1::String((*v).into())))
467                .collect(),
468            ..Default::default()
469        }
470    }
471
472    fn map_to_owned(values: &[&str]) -> Vec<String> {
473        values.iter().map(|&s| s.to_owned()).collect()
474    }
475
476    fn require_str(tags: &[&str]) -> TraceFilterer {
477        TraceFilterer::new(&map_to_owned(tags), &[], &[], &[], &[])
478    }
479
480    fn reject_str(tags: &[&str]) -> TraceFilterer {
481        TraceFilterer::new(&[], &map_to_owned(tags), &[], &[], &[])
482    }
483
484    fn require_regex(tags: &[&str]) -> TraceFilterer {
485        TraceFilterer::new(&[], &[], &map_to_owned(tags), &[], &[])
486    }
487
488    fn reject_regex(tags: &[&str]) -> TraceFilterer {
489        TraceFilterer::new(&[], &[], &[], &map_to_owned(tags), &[])
490    }
491
492    fn ignore_resources(patterns: &[&str]) -> TraceFilterer {
493        TraceFilterer::new(&[], &[], &[], &[], &map_to_owned(patterns))
494    }
495
496    // ---- reject (TagStringFilter) ----
497
498    #[test]
499    fn reject_string_exact_match_drops() {
500        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
501        reject_str(&["env:prod"]).filter_traces(&mut traces);
502        assert!(traces.is_empty());
503    }
504
505    #[test]
506    fn reject_string_wrong_value_keeps() {
507        let mut traces = one_trace(span_with("r", &[("env", "staging")]));
508        reject_str(&["env:prod"]).filter_traces(&mut traces);
509        assert_eq!(traces.len(), 1);
510    }
511
512    #[test]
513    fn reject_string_missing_tag_keeps() {
514        let mut traces = one_trace(span_with("r", &[]));
515        reject_str(&["env:prod"]).filter_traces(&mut traces);
516        assert_eq!(traces.len(), 1);
517    }
518
519    #[test]
520    fn reject_string_key_only_matches_any_value() {
521        // A key-only filter (no `:value` part) matches regardless of the tag's value.
522        let mut traces = one_trace(span_with("r", &[("env", "anything")]));
523        reject_str(&["env"]).filter_traces(&mut traces);
524        assert!(traces.is_empty());
525    }
526
527    // ---- reject_regex (TagRegexFilter – literal key, regex value) ----
528
529    #[test]
530    fn reject_regex_value_match_drops() {
531        let mut traces = one_trace(span_with("r", &[("env", "production")]));
532        reject_regex(&["env:prod.*"]).filter_traces(&mut traces);
533        assert!(traces.is_empty());
534    }
535
536    #[cfg_attr(miri, ignore)] // regex compilation is prohibitively slow under Miri
537    #[test]
538    fn reject_regex_value_no_match_keeps() {
539        let mut traces = one_trace(span_with("r", &[("env", "staging")]));
540        reject_regex(&["env:prod.*"]).filter_traces(&mut traces);
541        assert_eq!(traces.len(), 1);
542    }
543
544    // ---- reject_key_regex ----
545    // Checks that it's not implemented
546
547    #[test]
548    fn reject_key_regex_key_and_value_match_drops() {
549        let mut traces = one_trace(span_with("r", &[("error", "timeout")]));
550        reject_regex(&["err.*:timeout"]).filter_traces(&mut traces);
551        // Regex keys are not implemented so it doesn't match
552        assert!(!traces.is_empty());
553    }
554
555    #[test]
556    fn reject_key_regex_wrong_value_keeps() {
557        let mut traces = one_trace(span_with("r", &[("error", "network")]));
558        reject_regex(&["err.*:timeout"]).filter_traces(&mut traces);
559        assert_eq!(traces.len(), 1);
560    }
561
562    #[test]
563    fn reject_key_regex_missing_key_keeps() {
564        let mut traces = one_trace(span_with("r", &[]));
565        reject_regex(&["err.*:timeout"]).filter_traces(&mut traces);
566        assert_eq!(traces.len(), 1);
567    }
568
569    // ---- require (TagStringFilter) ----
570
571    #[test]
572    fn require_string_present_and_matching_keeps() {
573        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
574        require_str(&["env:prod"]).filter_traces(&mut traces);
575        assert_eq!(traces.len(), 1);
576    }
577
578    #[test]
579    fn require_string_missing_tag_drops() {
580        let mut traces = one_trace(span_with("r", &[]));
581        require_str(&["env:prod"]).filter_traces(&mut traces);
582        assert!(traces.is_empty());
583    }
584
585    #[test]
586    fn require_string_wrong_value_drops() {
587        let mut traces = one_trace(span_with("r", &[("env", "staging")]));
588        require_str(&["env:prod"]).filter_traces(&mut traces);
589        assert!(traces.is_empty());
590    }
591
592    // ---- require_regex (TagRegexFilter – literal key, regex value) ----
593
594    #[cfg_attr(miri, ignore)] // regex compilation is prohibitively slow under Miri
595    #[test]
596    fn require_regex_value_match_keeps() {
597        let mut traces = one_trace(span_with("r", &[("env", "production")]));
598        require_regex(&["env:prod.*"]).filter_traces(&mut traces);
599        assert_eq!(traces.len(), 1);
600    }
601
602    #[test]
603    fn require_regex_missing_drops() {
604        let mut traces = one_trace(span_with("r", &[]));
605        require_regex(&["env:prod.*"]).filter_traces(&mut traces);
606        assert!(traces.is_empty());
607    }
608
609    // ---- require_key_regex  ----
610    // (Checks that it's not implemented)
611
612    #[test]
613    fn require_key_regex_key_exists_keeps() {
614        let mut traces = one_trace(span_with("r", &[("error", "any")]));
615        require_regex(&["err.*"]).filter_traces(&mut traces);
616        // Regex keys are not implemented so it doesn't match
617        assert!(traces.is_empty());
618    }
619
620    #[test]
621    fn require_key_regex_missing_key_drops() {
622        let mut traces = one_trace(span_with("r", &[]));
623        require_regex(&["err.*"]).filter_traces(&mut traces);
624        assert!(traces.is_empty());
625    }
626
627    // ---- ignore_resources ----
628
629    #[test]
630    fn ignore_resources_match_drops() {
631        let mut traces = one_trace(span_with("GET /health", &[]));
632        ignore_resources(&["GET /health"]).filter_traces(&mut traces);
633        assert!(traces.is_empty());
634    }
635
636    #[test]
637    fn ignore_resources_no_match_keeps() {
638        let mut traces = one_trace(span_with("POST /data", &[]));
639        ignore_resources(&["GET /health"]).filter_traces(&mut traces);
640        assert_eq!(traces.len(), 1);
641    }
642
643    #[test]
644    fn ignore_resources_empty_resource_falls_back_to_name() {
645        // When resource is empty the span's name field is used for matching.
646        // The helper sets name = "op", so ignore_resources("op") must drop it.
647        let mut traces = one_trace(span_with("", &[]));
648        ignore_resources(&["op"]).filter_traces(&mut traces);
649        assert!(traces.is_empty());
650    }
651
652    // ---- env tag normalization ----
653
654    #[test]
655    fn env_normalization_reject_matches_after_lowercase() {
656        // normalize_tag_cloned("PROD") == "prod"; the reject filter "env:prod" must fire.
657        let mut traces = one_trace(span_with("r", &[("env", "PROD")]));
658        reject_str(&["env:prod"]).filter_traces(&mut traces);
659        assert!(
660            traces.is_empty(),
661            "env value should be normalized before matching"
662        );
663    }
664
665    #[test]
666    fn env_normalization_require_matches_normalized_value() {
667        // normalize_tag_cloned("Prod Env") == "prod_env" (uppercase + space → underscore).
668        let mut traces = one_trace(span_with("r", &[("env", "Prod Env")]));
669        require_str(&["env:prod_env"]).filter_traces(&mut traces);
670        assert_eq!(
671            traces.len(),
672            1,
673            "normalized env should satisfy the require filter"
674        );
675    }
676
677    // ---- http.status_code special handling ----
678
679    #[test]
680    fn http_status_code_invalid_value_skips_reject_filter() {
681        // is_valid_http_status_code("abc") == false → check_tag_filter returns false
682        // → reject never fires → trace kept even though the raw value equals the filter.
683        let mut traces = one_trace(span_with("r", &[("http.status_code", "abc")]));
684        reject_str(&["http.status_code:abc"]).filter_traces(&mut traces);
685        assert_eq!(
686            traces.len(),
687            1,
688            "invalid status code should not trigger the filter"
689        );
690    }
691
692    #[test]
693    fn http_status_code_valid_value_triggers_reject_filter() {
694        let mut traces = one_trace(span_with("r", &[("http.status_code", "500")]));
695        reject_str(&["http.status_code:500"]).filter_traces(&mut traces);
696        assert!(traces.is_empty());
697    }
698
699    // ---- edge / misc ----
700
701    #[test]
702    fn multiple_traces_partial_rejection() {
703        let f = reject_str(&["env:prod"]);
704        let mut traces = vec![
705            vec![span_with("r", &[("env", "prod")])],    // dropped
706            vec![span_with("r", &[("env", "staging")])], // kept
707        ];
708        f.filter_traces(&mut traces);
709        assert_eq!(traces.len(), 1);
710    }
711
712    #[test]
713    fn no_filters_keeps_all_traces() {
714        let f = TraceFilterer::new(&[], &[], &[], &[], &[]);
715        let mut traces = vec![
716            vec![span_with("r1", &[])],
717            vec![span_with("r2", &[("env", "prod")])],
718        ];
719        f.filter_traces(&mut traces);
720        assert_eq!(traces.len(), 2);
721    }
722
723    #[test]
724    fn invalid_regex_in_filter_is_skipped_gracefully() {
725        // A bad regex pattern is silently discarded; no panic, trace is kept.
726        let f = reject_regex(&["env:[invalid"]);
727        let mut traces = one_trace(span_with("r", &[("env", "anything")]));
728        f.filter_traces(&mut traces);
729        assert_eq!(traces.len(), 1);
730    }
731
732    // ---- key/value trimming ----
733
734    #[test]
735    fn literal_reject_spaces_around_colon_drops() {
736        // " env : prod " → key="env", value="prod"
737        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
738        reject_str(&[" env : prod "]).filter_traces(&mut traces);
739        assert!(traces.is_empty());
740    }
741
742    #[test]
743    fn literal_require_spaces_around_colon_keeps() {
744        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
745        require_str(&[" env : prod "]).filter_traces(&mut traces);
746        assert_eq!(traces.len(), 1);
747    }
748
749    #[test]
750    fn literal_reject_key_only_with_spaces_drops_any_value() {
751        // " env " (no colon) → key="env", no value requirement
752        let mut traces = one_trace(span_with("r", &[("env", "anything")]));
753        reject_str(&[" env "]).filter_traces(&mut traces);
754        assert!(traces.is_empty());
755    }
756
757    #[test]
758    fn literal_reject_empty_key_is_skipped_keeps() {
759        // ":prod" → key="" → filter skipped → trace kept
760        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
761        reject_str(&[":prod"]).filter_traces(&mut traces);
762        assert_eq!(traces.len(), 1);
763    }
764
765    #[test]
766    fn literal_require_empty_key_is_skipped_keeps() {
767        // ":prod" → filter skipped → require list empty → vacuous all() → trace kept
768        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
769        require_str(&[":prod"]).filter_traces(&mut traces);
770        assert_eq!(traces.len(), 1);
771    }
772
773    #[test]
774    fn regex_reject_spaces_around_colon_drops() {
775        // " env : prod.* " → key="env", regex="prod.*"
776        let mut traces = one_trace(span_with("r", &[("env", "production")]));
777        reject_regex(&[" env : prod.* "]).filter_traces(&mut traces);
778        assert!(traces.is_empty());
779    }
780
781    #[cfg_attr(miri, ignore)] // regex compilation is prohibitively slow under Miri
782    #[test]
783    fn regex_require_spaces_around_colon_keeps() {
784        let mut traces = one_trace(span_with("r", &[("env", "production")]));
785        require_regex(&[" env : prod.* "]).filter_traces(&mut traces);
786        assert_eq!(traces.len(), 1);
787    }
788
789    #[test]
790    fn regex_reject_empty_key_is_skipped_keeps() {
791        // ":prod.*" → key="" → filter skipped → trace kept
792        let mut traces = one_trace(span_with("r", &[("env", "prod")]));
793        reject_regex(&[":prod.*"]).filter_traces(&mut traces);
794        assert_eq!(traces.len(), 1);
795    }
796
797    // ---- filter_traces_v1 ----
798
799    #[test]
800    fn v1_reject_string_exact_match_drops() {
801        let mut traces = vec![v1_chunk_with("r", &[("env", "prod")])];
802        reject_str(&["env:prod"]).filter_traces_v1(&mut traces);
803        assert!(traces.is_empty());
804    }
805
806    #[test]
807    fn v1_reject_string_wrong_value_keeps() {
808        let mut traces = vec![v1_chunk_with("r", &[("env", "staging")])];
809        reject_str(&["env:prod"]).filter_traces_v1(&mut traces);
810        assert_eq!(traces.len(), 1);
811    }
812
813    #[test]
814    fn v1_ignore_resources_match_drops() {
815        let mut traces = vec![v1_chunk_with("GET /health", &[])];
816        ignore_resources(&["GET /health"]).filter_traces_v1(&mut traces);
817        assert!(traces.is_empty());
818    }
819
820    #[test]
821    fn v1_reject_matches_chunk_level_attribute() {
822        // The root span itself has no "env" tag, only the chunk does; the filter should still
823        // see it via the chunk-attributes fallback.
824        let mut traces = vec![v1_chunk_with_chunk_attributes("r", &[("env", "prod")])];
825        reject_str(&["env:prod"]).filter_traces_v1(&mut traces);
826        assert!(traces.is_empty());
827    }
828
829    #[test]
830    fn v1_span_level_attribute_takes_precedence_over_chunk() {
831        // The span's own value should win over the chunk-level fallback.
832        let mut chunk = v1_chunk_with("r", &[("env", "staging")]);
833        chunk.attributes = [("env".into(), AttributeValueV1::String("prod".into()))]
834            .into_iter()
835            .collect();
836        let mut traces = vec![chunk];
837        reject_str(&["env:prod"]).filter_traces_v1(&mut traces);
838        assert_eq!(traces.len(), 1, "span-level value should take precedence");
839    }
840
841    #[test]
842    fn v1_reject_matches_dedicated_promoted_fields() {
843        // env/version/component/span.kind are stored in dedicated fields rather than
844        // `attributes`; the filter must still see them there.
845        let mut traces = vec![TraceChunk {
846            spans: vec![SpanBytesV1 {
847                service: "svc".into(),
848                name: "op".into(),
849                resource: "r".into(),
850                span_id: 1,
851                parent_id: 0,
852                env: "prod".into(),
853                version: "1.2.3".into(),
854                component: "http".into(),
855                span_kind: crate::span::v1::SpanKind::Client,
856                ..Default::default()
857            }],
858            ..Default::default()
859        }];
860
861        reject_str(&["env:prod"]).filter_traces_v1(&mut traces);
862        assert!(
863            traces.is_empty(),
864            "dedicated env field should be visible to the filter"
865        );
866    }
867}