relay_filter/
config.rs

1//! Config structs for all filters.
2
3use std::borrow::Cow;
4use std::collections::BTreeSet;
5use std::convert::Infallible;
6use std::fmt;
7use std::ops::Deref;
8use std::str::FromStr;
9
10use indexmap::IndexMap;
11use relay_pattern::{CaseInsensitive, TypedPatterns};
12use relay_protocol::RuleCondition;
13use serde::ser::SerializeSeq;
14use serde::{Deserialize, Serialize, Serializer, de};
15
16/// Common configuration for event filters.
17#[derive(Clone, Debug, Default, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct FilterConfig {
20    /// Specifies whether this filter is enabled.
21    pub is_enabled: bool,
22}
23
24impl FilterConfig {
25    /// Returns true if no configuration for this filter is given.
26    pub fn is_empty(&self) -> bool {
27        !self.is_enabled
28    }
29}
30
31/// A browser class to be filtered by the legacy browser filter.
32#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
33pub enum LegacyBrowser {
34    /// Applies the default set of min-version filters for all known browsers.
35    Default,
36    /// Apply to Internet Explorer 8 and older.
37    IePre9,
38    /// Apply to Internet Explorer 9.
39    Ie9,
40    /// Apply to Internet Explorer 10.
41    Ie10,
42    /// Apply to Internet Explorer 11.
43    Ie11,
44    /// Apply to Opera 14 and older.
45    OperaPre15,
46    /// Apply to OperaMini 7 and older.
47    OperaMiniPre8,
48    /// Apply to Android (Chrome) 3 and older.
49    AndroidPre4,
50    /// Apply to Safari 5 and older.
51    SafariPre6,
52    /// Edge legacy i.e. 12-18.
53    EdgePre79,
54    /// Apply to Internet Explorer
55    Ie,
56    /// Apply to Safari
57    Safari,
58    /// Apply to Opera
59    Opera,
60    /// Apply to OperaMini
61    OperaMini,
62    /// Apply to Android Browser
63    Android,
64    /// Apply to Firefox
65    Firefox,
66    /// Apply to Chrome
67    Chrome,
68    /// Apply to Edge
69    Edge,
70    /// An unknown browser configuration for forward compatibility.
71    Unknown(String),
72}
73
74impl FromStr for LegacyBrowser {
75    type Err = Infallible;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        let v = match s {
79            "default" => LegacyBrowser::Default,
80            "ie_pre_9" => LegacyBrowser::IePre9,
81            "ie9" => LegacyBrowser::Ie9,
82            "ie10" => LegacyBrowser::Ie10,
83            "ie11" => LegacyBrowser::Ie11,
84            "opera_pre_15" => LegacyBrowser::OperaPre15,
85            "opera_mini_pre_8" => LegacyBrowser::OperaMiniPre8,
86            "android_pre_4" => LegacyBrowser::AndroidPre4,
87            "safari_pre_6" => LegacyBrowser::SafariPre6,
88            "edge_pre_79" => LegacyBrowser::EdgePre79,
89            "ie" => LegacyBrowser::Ie,
90            "safari" => LegacyBrowser::Safari,
91            "opera" => LegacyBrowser::Opera,
92            "opera_mini" => LegacyBrowser::OperaMini,
93            "android" => LegacyBrowser::Android,
94            "firefox" => LegacyBrowser::Firefox,
95            "chrome" => LegacyBrowser::Chrome,
96            "edge" => LegacyBrowser::Edge,
97            _ => LegacyBrowser::Unknown(s.to_owned()),
98        };
99        Ok(v)
100    }
101}
102
103impl<'de> Deserialize<'de> for LegacyBrowser {
104    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
105    where
106        D: serde::de::Deserializer<'de>,
107    {
108        let s = Cow::<str>::deserialize(deserializer)?;
109        Ok(LegacyBrowser::from_str(s.as_ref()).unwrap())
110    }
111}
112
113impl Serialize for LegacyBrowser {
114    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
115    where
116        S: serde::ser::Serializer,
117    {
118        serializer.serialize_str(match self {
119            LegacyBrowser::Default => "default",
120            LegacyBrowser::IePre9 => "ie_pre_9",
121            LegacyBrowser::Ie9 => "ie9",
122            LegacyBrowser::Ie10 => "ie10",
123            LegacyBrowser::Ie11 => "ie11",
124            LegacyBrowser::OperaPre15 => "opera_pre_15",
125            LegacyBrowser::OperaMiniPre8 => "opera_mini_pre_8",
126            LegacyBrowser::AndroidPre4 => "android_pre_4",
127            LegacyBrowser::SafariPre6 => "safari_pre_6",
128            LegacyBrowser::EdgePre79 => "edge_pre_79",
129            LegacyBrowser::Ie => "ie",
130            LegacyBrowser::Safari => "safari",
131            LegacyBrowser::Opera => "opera",
132            LegacyBrowser::OperaMini => "opera_mini",
133            LegacyBrowser::Android => "android",
134            LegacyBrowser::Firefox => "firefox",
135            LegacyBrowser::Chrome => "chrome",
136            LegacyBrowser::Edge => "edge",
137            LegacyBrowser::Unknown(string) => string,
138        })
139    }
140}
141
142/// Configuration for the client ips filter.
143#[derive(Clone, Debug, Default, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct ClientIpsFilterConfig {
146    /// Blacklisted client ip addresses.
147    pub blacklisted_ips: Vec<String>,
148}
149
150impl ClientIpsFilterConfig {
151    /// Returns true if no configuration for this filter is given.
152    pub fn is_empty(&self) -> bool {
153        self.blacklisted_ips.is_empty()
154    }
155}
156
157/// Configuration for the CSP filter.
158#[derive(Clone, Debug, Default, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct CspFilterConfig {
161    /// Disallowed sources for CSP reports.
162    pub disallowed_sources: Vec<String>,
163}
164
165impl CspFilterConfig {
166    /// Returns true if no configuration for this filter is given.
167    pub fn is_empty(&self) -> bool {
168        self.disallowed_sources.is_empty()
169    }
170}
171
172/// Configuration for the error messages filter.
173#[derive(Clone, Debug, Default, Serialize, Deserialize)]
174pub struct ErrorMessagesFilterConfig {
175    /// List of error message patterns that will be filtered.
176    pub patterns: TypedPatterns<CaseInsensitive>,
177}
178
179/// Configuration for transaction name filter.
180#[derive(Clone, Debug, Default, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct IgnoreTransactionsFilterConfig {
183    /// List of patterns for ignored transactions that should be filtered.
184    pub patterns: TypedPatterns<CaseInsensitive>,
185    /// True if the filter is enabled
186    #[serde(default)]
187    pub is_enabled: bool,
188}
189
190impl IgnoreTransactionsFilterConfig {
191    /// Returns true if no configuration for this filter is given.
192    pub fn is_empty(&self) -> bool {
193        self.patterns.is_empty() || !self.is_enabled
194    }
195}
196
197impl ErrorMessagesFilterConfig {
198    /// Returns true if no configuration for this filter is given.
199    pub fn is_empty(&self) -> bool {
200        self.patterns.is_empty()
201    }
202}
203
204/// Configuration for the releases filter.
205#[derive(Clone, Debug, Default, Serialize, Deserialize)]
206pub struct ReleasesFilterConfig {
207    /// List of release names that will be filtered.
208    pub releases: TypedPatterns<CaseInsensitive>,
209}
210
211impl ReleasesFilterConfig {
212    /// Returns true if no configuration for this filter is given.
213    pub fn is_empty(&self) -> bool {
214        self.releases.is_empty()
215    }
216}
217
218/// Configuration for the legacy browsers filter.
219#[derive(Clone, Debug, Default, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct LegacyBrowsersFilterConfig {
222    /// Specifies whether this filter is enabled.
223    pub is_enabled: bool,
224    /// The browsers to filter.
225    #[serde(default, rename = "options")]
226    pub browsers: BTreeSet<LegacyBrowser>,
227}
228
229impl LegacyBrowsersFilterConfig {
230    /// Returns true if no configuration for this filter is given.
231    pub fn is_empty(&self) -> bool {
232        !self.is_enabled && self.browsers.is_empty()
233    }
234}
235
236/// Configuration for a generic filter.
237#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
238#[serde(rename_all = "camelCase")]
239pub struct GenericFilterConfig {
240    /// Unique identifier of the generic filter.
241    pub id: String,
242    /// Specifies whether this filter is enabled.
243    pub is_enabled: bool,
244    /// The condition for the filter.
245    pub condition: Option<RuleCondition>,
246}
247
248impl GenericFilterConfig {
249    /// Returns true if the filter is not enabled or no condition was supplied.
250    pub fn is_empty(&self) -> bool {
251        !self.is_enabled || self.condition.is_none()
252    }
253}
254
255/// Configuration for generic filters.
256///
257/// # Deserialization
258///
259/// `filters` is expected to be a sequence of [`GenericFilterConfig`].
260/// Only the first occurrence of a filter is kept, and duplicates are removed.
261/// Two filters are considered duplicates if they have the same ID,
262/// independently of the condition.
263///
264/// The list of filters is deserialized into an [`GenericFiltersMap`], where the
265/// key is the filter's id and the value is the filter itself. The map is
266/// converted back to a list when serializing it, without the filters that were
267/// discarded as duplicates. See examples below.
268///
269/// # Iterator
270///
271/// Iterates in order through the generic filters in project configs and global
272/// configs yielding the filters according to the principles below:
273///
274/// - Filters from project configs are evaluated before filters from global
275///   configs.
276/// - No duplicates: once a filter is evaluated (yielded or skipped), no filter
277///   with the same id is evaluated again.
278/// - Filters in project configs override filters from global configs, but the
279///   opposite is never the case.
280/// - A filter in the project config can be a flag, where only `is_enabled` is
281///   defined and `condition` is not. In that case:
282///   - If `is_enabled` is true, the filter with a matching ID from global
283///     configs is yielded without evaluating its `is_enabled`. Unless the filter
284///     in the global config also has an empty condition, in which case the filter
285///     is not yielded.
286///   - If `is_enabled` is false, no filters with the same IDs are returned,
287///     including matching filters from global configs.
288///
289/// # Examples
290///
291/// Deserialization:
292///
293/// ```
294/// # use relay_filter::GenericFiltersConfig;
295/// # use insta::assert_debug_snapshot;
296///
297/// let json = r#"{
298///     "version": 1,
299///     "filters": [
300///         {
301///             "id": "filter1",
302///             "isEnabled": false,
303///             "condition": null
304///         },
305///         {
306///             "id": "filter1",
307///             "isEnabled": true,
308///             "condition": {
309///                 "op": "eq",
310///                 "name": "event.exceptions",
311///                 "value": "drop-error"
312///             }
313///         }
314///     ]
315/// }"#;
316/// let deserialized = serde_json::from_str::<GenericFiltersConfig>(json).unwrap();
317/// assert_debug_snapshot!(deserialized, @r#"
318///     GenericFiltersConfig {
319///         version: 1,
320///         filters: GenericFiltersMap(
321///             {
322///                 "filter1": GenericFilterConfig {
323///                     id: "filter1",
324///                     is_enabled: false,
325///                     condition: None,
326///                 },
327///             },
328///         ),
329///     }
330/// "#);
331/// ```
332///
333/// Deserialization of no filters defaults to an empty map:
334///
335/// ```
336/// # use relay_filter::GenericFiltersConfig;
337/// # use insta::assert_debug_snapshot;
338///
339/// let json = r#"{
340///     "version": 1
341/// }"#;
342/// let deserialized = serde_json::from_str::<GenericFiltersConfig>(json).unwrap();
343/// assert_debug_snapshot!(deserialized, @r#"
344///     GenericFiltersConfig {
345///         version: 1,
346///         filters: GenericFiltersMap(
347///             {},
348///         ),
349///     }
350/// "#);
351/// ```
352///
353/// Serialization:
354///
355/// ```
356/// # use relay_filter::{GenericFiltersConfig, GenericFilterConfig};
357/// # use relay_protocol::condition::RuleCondition;
358/// # use insta::assert_display_snapshot;
359///
360/// let filter = GenericFiltersConfig {
361///     version: 1,
362///     filters: vec![
363///         GenericFilterConfig {
364///             id: "filter1".to_owned(),
365///             is_enabled: true,
366///             condition: Some(RuleCondition::eq("event.exceptions", "drop-error")),
367///         },
368///     ].into(),
369/// };
370/// let serialized = serde_json::to_string_pretty(&filter).unwrap();
371/// assert_display_snapshot!(serialized, @r#"{
372///   "version": 1,
373///   "filters": [
374///     {
375///       "id": "filter1",
376///       "isEnabled": true,
377///       "condition": {
378///         "op": "eq",
379///         "name": "event.exceptions",
380///         "value": "drop-error"
381///       }
382///     }
383///   ]
384/// }"#);
385/// ```
386///
387/// Serialization of filters is skipped if there aren't any:
388///
389/// ```
390/// # use relay_filter::{GenericFiltersConfig, GenericFilterConfig, GenericFiltersMap};
391/// # use relay_protocol::condition::RuleCondition;
392/// # use insta::assert_display_snapshot;
393///
394/// let filter = GenericFiltersConfig {
395///     version: 1,
396///     filters: GenericFiltersMap::new(),
397/// };
398/// let serialized = serde_json::to_string_pretty(&filter).unwrap();
399/// assert_display_snapshot!(serialized, @r#"{
400///   "version": 1
401/// }"#);
402/// ```
403#[derive(Clone, Debug, Default, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct GenericFiltersConfig {
406    /// Version of the filters configuration.
407    pub version: u16,
408    /// Map of generic filters, sorted by the order in the payload from upstream.
409    ///
410    /// The map contains unique filters, meaning there are no two filters with
411    /// the same id. See struct docs for more details.
412    #[serde(default, skip_serializing_if = "GenericFiltersMap::is_empty")]
413    pub filters: GenericFiltersMap,
414}
415
416impl GenericFiltersConfig {
417    /// Returns true if the filters are not declared.
418    pub fn is_empty(&self) -> bool {
419        self.filters.is_empty()
420    }
421}
422
423/// Map of generic filters, mapping from the id to the filter itself.
424#[derive(Clone, Debug, Default)]
425pub struct GenericFiltersMap(IndexMap<String, GenericFilterConfig>);
426
427impl GenericFiltersMap {
428    /// Returns an empty map.
429    pub fn new() -> Self {
430        GenericFiltersMap(IndexMap::new())
431    }
432
433    /// Returns whether the map is empty.
434    pub fn is_empty(&self) -> bool {
435        self.0.is_empty()
436    }
437}
438
439impl From<Vec<GenericFilterConfig>> for GenericFiltersMap {
440    fn from(filters: Vec<GenericFilterConfig>) -> Self {
441        let mut map = IndexMap::with_capacity(filters.len());
442        for filter in filters {
443            if !map.contains_key(&filter.id) {
444                map.insert(filter.id.clone(), filter);
445            }
446        }
447        GenericFiltersMap(map)
448    }
449}
450
451impl Deref for GenericFiltersMap {
452    type Target = IndexMap<String, GenericFilterConfig>;
453
454    fn deref(&self) -> &Self::Target {
455        &self.0
456    }
457}
458
459impl<'de> Deserialize<'de> for GenericFiltersMap {
460    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
461    where
462        D: de::Deserializer<'de>,
463    {
464        struct GenericFiltersVisitor;
465
466        impl<'de> serde::de::Visitor<'de> for GenericFiltersVisitor {
467            type Value = GenericFiltersMap;
468
469            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
470                formatter.write_str("a vector of filters: Vec<GenericFilterConfig>")
471            }
472
473            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
474            where
475                A: de::SeqAccess<'de>,
476            {
477                let mut filters = IndexMap::with_capacity(seq.size_hint().unwrap_or(0));
478                while let Some(filter) = seq.next_element::<GenericFilterConfig>()? {
479                    if !filters.contains_key(&filter.id) {
480                        filters.insert(filter.id.clone(), filter);
481                    }
482                }
483                Ok(GenericFiltersMap(filters))
484            }
485        }
486
487        deserializer.deserialize_seq(GenericFiltersVisitor)
488    }
489}
490
491impl Serialize for GenericFiltersMap {
492    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
493    where
494        S: Serializer,
495    {
496        let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
497        for filter in self.0.values() {
498            seq.serialize_element(filter)?;
499        }
500        seq.end()
501    }
502}
503
504/// Configuration for all event filters from project configs.
505#[derive(Clone, Debug, Default, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct ProjectFiltersConfig {
508    /// Configuration for the Browser Extensions filter.
509    #[serde(default, skip_serializing_if = "FilterConfig::is_empty")]
510    pub browser_extensions: FilterConfig,
511
512    /// Configuration for the Client IPs filter.
513    #[serde(default, skip_serializing_if = "ClientIpsFilterConfig::is_empty")]
514    pub client_ips: ClientIpsFilterConfig,
515
516    /// Configuration for the Web Crawlers filter
517    #[serde(default, skip_serializing_if = "FilterConfig::is_empty")]
518    pub web_crawlers: FilterConfig,
519
520    /// Configuration for the CSP filter.
521    #[serde(default, skip_serializing_if = "CspFilterConfig::is_empty")]
522    pub csp: CspFilterConfig,
523
524    /// Configuration for the Error Messages filter.
525    #[serde(default, skip_serializing_if = "ErrorMessagesFilterConfig::is_empty")]
526    pub error_messages: ErrorMessagesFilterConfig,
527
528    /// Configuration for the Legacy Browsers filter.
529    #[serde(default, skip_serializing_if = "LegacyBrowsersFilterConfig::is_empty")]
530    pub legacy_browsers: LegacyBrowsersFilterConfig,
531
532    /// Configuration for the Localhost filter.
533    #[serde(default, skip_serializing_if = "FilterConfig::is_empty")]
534    pub localhost: FilterConfig,
535
536    /// Configuration for the releases filter.
537    #[serde(default, skip_serializing_if = "ReleasesFilterConfig::is_empty")]
538    pub releases: ReleasesFilterConfig,
539
540    /// Configuration for ignore transactions filter.
541    #[serde(
542        default,
543        skip_serializing_if = "IgnoreTransactionsFilterConfig::is_empty"
544    )]
545    pub ignore_transactions: IgnoreTransactionsFilterConfig,
546
547    /// Configuration for generic filters from the project configs.
548    #[serde(default, skip_serializing_if = "GenericFiltersConfig::is_empty")]
549    pub generic: GenericFiltersConfig,
550}
551
552impl ProjectFiltersConfig {
553    /// Returns true if there are no filter configurations declared.
554    pub fn is_empty(&self) -> bool {
555        self.browser_extensions.is_empty()
556            && self.client_ips.is_empty()
557            && self.web_crawlers.is_empty()
558            && self.csp.is_empty()
559            && self.error_messages.is_empty()
560            && self.legacy_browsers.is_empty()
561            && self.localhost.is_empty()
562            && self.releases.is_empty()
563            && self.ignore_transactions.is_empty()
564            && self.generic.is_empty()
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_empty_config() -> Result<(), serde_json::Error> {
574        let filters_config = serde_json::from_str::<ProjectFiltersConfig>("{}")?;
575        insta::assert_debug_snapshot!(filters_config, @r###"
576        ProjectFiltersConfig {
577            browser_extensions: FilterConfig {
578                is_enabled: false,
579            },
580            client_ips: ClientIpsFilterConfig {
581                blacklisted_ips: [],
582            },
583            web_crawlers: FilterConfig {
584                is_enabled: false,
585            },
586            csp: CspFilterConfig {
587                disallowed_sources: [],
588            },
589            error_messages: ErrorMessagesFilterConfig {
590                patterns: [],
591            },
592            legacy_browsers: LegacyBrowsersFilterConfig {
593                is_enabled: false,
594                browsers: {},
595            },
596            localhost: FilterConfig {
597                is_enabled: false,
598            },
599            releases: ReleasesFilterConfig {
600                releases: [],
601            },
602            ignore_transactions: IgnoreTransactionsFilterConfig {
603                patterns: [],
604                is_enabled: false,
605            },
606            generic: GenericFiltersConfig {
607                version: 0,
608                filters: GenericFiltersMap(
609                    {},
610                ),
611            },
612        }
613        "###);
614        Ok(())
615    }
616
617    #[test]
618    fn test_serialize_empty() {
619        let filters_config = ProjectFiltersConfig::default();
620        insta::assert_json_snapshot!(filters_config, @"{}");
621    }
622
623    #[test]
624    fn test_serialize_full() {
625        let filters_config = ProjectFiltersConfig {
626            browser_extensions: FilterConfig { is_enabled: true },
627            client_ips: ClientIpsFilterConfig {
628                blacklisted_ips: vec!["127.0.0.1".to_string()],
629            },
630            web_crawlers: FilterConfig { is_enabled: true },
631            csp: CspFilterConfig {
632                disallowed_sources: vec!["https://*".to_string()],
633            },
634            error_messages: ErrorMessagesFilterConfig {
635                patterns: TypedPatterns::from(["Panic".to_owned()]),
636            },
637            legacy_browsers: LegacyBrowsersFilterConfig {
638                is_enabled: false,
639                browsers: [LegacyBrowser::Ie9, LegacyBrowser::EdgePre79]
640                    .iter()
641                    .cloned()
642                    .collect(),
643            },
644            localhost: FilterConfig { is_enabled: true },
645            releases: ReleasesFilterConfig {
646                releases: TypedPatterns::from(["1.2.3".to_owned()]),
647            },
648            ignore_transactions: IgnoreTransactionsFilterConfig {
649                patterns: TypedPatterns::from(["*health*".to_owned()]),
650                is_enabled: true,
651            },
652            generic: GenericFiltersConfig {
653                version: 1,
654                filters: vec![GenericFilterConfig {
655                    id: "hydrationError".to_owned(),
656                    is_enabled: true,
657                    condition: Some(RuleCondition::eq("event.exceptions", "HydrationError")),
658                }]
659                .into(),
660            },
661        };
662
663        insta::assert_json_snapshot!(filters_config, @r#"
664        {
665          "browserExtensions": {
666            "isEnabled": true
667          },
668          "clientIps": {
669            "blacklistedIps": [
670              "127.0.0.1"
671            ]
672          },
673          "webCrawlers": {
674            "isEnabled": true
675          },
676          "csp": {
677            "disallowedSources": [
678              "https://*"
679            ]
680          },
681          "errorMessages": {
682            "patterns": [
683              "Panic"
684            ]
685          },
686          "legacyBrowsers": {
687            "isEnabled": false,
688            "options": [
689              "ie9",
690              "edge_pre_79"
691            ]
692          },
693          "localhost": {
694            "isEnabled": true
695          },
696          "releases": {
697            "releases": [
698              "1.2.3"
699            ]
700          },
701          "ignoreTransactions": {
702            "patterns": [
703              "*health*"
704            ],
705            "isEnabled": true
706          },
707          "generic": {
708            "version": 1,
709            "filters": [
710              {
711                "id": "hydrationError",
712                "isEnabled": true,
713                "condition": {
714                  "op": "eq",
715                  "name": "event.exceptions",
716                  "value": "HydrationError"
717                }
718              }
719            ]
720          }
721        }
722        "#);
723    }
724
725    #[test]
726    fn test_regression_legacy_browser_missing_options() {
727        let json = r#"{"isEnabled":false}"#;
728        let config = serde_json::from_str::<LegacyBrowsersFilterConfig>(json).unwrap();
729        insta::assert_debug_snapshot!(config, @r###"
730        LegacyBrowsersFilterConfig {
731            is_enabled: false,
732            browsers: {},
733        }
734        "###);
735    }
736
737    #[test]
738    fn test_deserialize_generic_filters() {
739        let json = r#"{
740            "version": 1,
741            "filters": [
742                {
743                  "id": "hydrationError",
744                  "isEnabled": true,
745                  "condition": {
746                    "op": "eq",
747                    "name": "event.exceptions",
748                    "value": "HydrationError"
749                  }
750                },
751                {
752                  "id": "chunkLoadError",
753                  "isEnabled": false
754                }
755           ]
756        }"#;
757        let config = serde_json::from_str::<GenericFiltersConfig>(json).unwrap();
758        insta::assert_debug_snapshot!(config, @r###"
759        GenericFiltersConfig {
760            version: 1,
761            filters: GenericFiltersMap(
762                {
763                    "hydrationError": GenericFilterConfig {
764                        id: "hydrationError",
765                        is_enabled: true,
766                        condition: Some(
767                            Eq(
768                                EqCondition {
769                                    name: "event.exceptions",
770                                    value: String("HydrationError"),
771                                    options: EqCondOptions {
772                                        ignore_case: false,
773                                    },
774                                },
775                            ),
776                        ),
777                    },
778                    "chunkLoadError": GenericFilterConfig {
779                        id: "chunkLoadError",
780                        is_enabled: false,
781                        condition: None,
782                    },
783                },
784            ),
785        }
786        "###);
787    }
788}