relay_event_normalization/normalize/span/description/
mod.rs

1//! Span description scrubbing logic.
2mod redis;
3mod resource;
4mod sql;
5use once_cell::sync::Lazy;
6use psl;
7use relay_filter::matches_any_origin;
8use serde_json::Value;
9#[cfg(test)]
10pub use sql::{Mode, scrub_queries};
11
12use relay_event_schema::protocol::Span;
13use std::borrow::Cow;
14use std::net::{Ipv4Addr, Ipv6Addr};
15use std::path::Path;
16use url::{Host, Url};
17
18use crate::regexes::{
19    DB_SQL_TRANSACTION_CORE_DATA_REGEX, DB_SUPABASE_REGEX, FUNCTION_NORMALIZER_REGEX,
20    RESOURCE_NORMALIZER_REGEX,
21};
22use crate::span::TABLE_NAME_REGEX;
23use crate::span::description::redis::matching_redis_command;
24use crate::span::description::resource::COMMON_PATH_SEGMENTS;
25use crate::span::tag_extraction::HTTP_METHOD_EXTRACTOR_REGEX;
26
27/// Dummy URL used to parse relative URLs.
28static DUMMY_BASE_URL: Lazy<Url> = Lazy::new(|| "http://replace_me".parse().unwrap());
29
30/// Maximum length of a resource URL segment.
31///
32/// Segments longer than this are treated as identifiers.
33const MAX_SEGMENT_LENGTH: usize = 25;
34
35/// Some bundlers attach characters to the end of a filename, try to catch those.
36const MAX_EXTENSION_LENGTH: usize = 10;
37
38/// Domain names that are preserved during scrubbing
39const DOMAIN_ALLOW_LIST: &[&str] = &["localhost"];
40
41/// Attempts to replace identifiers in the span description with placeholders.
42///
43/// Returns `None` if no scrubbing can be performed.
44pub(crate) fn scrub_span_description(
45    span: &Span,
46    span_allowed_hosts: &[String],
47) -> (Option<String>, Option<Vec<sqlparser::ast::Statement>>) {
48    let Some(description) = span.description.as_str() else {
49        return (None, None);
50    };
51
52    let data = span.data.value();
53
54    let db_system = data
55        .and_then(|data| data.db_system.value())
56        .and_then(|system| system.as_str());
57    let span_origin = span.origin.as_str();
58
59    let mut parsed_sql = None;
60    let scrubbed_description = span
61        .op
62        .as_str()
63        .map(|op| op.split_once('.').unwrap_or((op, "")))
64        .and_then(|(op, sub)| match (op, sub) {
65            ("http", _) => scrub_http(description, span_allowed_hosts),
66            ("cache", _) | ("db", "redis") => scrub_redis_keys(description),
67            ("db", _) if db_system == Some("redis") => scrub_redis_keys(description),
68            ("db", _) if db_system == Some("mongodb") => {
69                let command = data
70                    .and_then(|data| data.db_operation.value())
71                    .and_then(|command| command.as_str());
72
73                let collection = data
74                    .and_then(|data| data.db_collection_name.value())
75                    .and_then(|collection| collection.as_str());
76
77                if let (Some(command), Some(collection)) = (command, collection) {
78                    scrub_mongodb_query(description, command, collection)
79                } else {
80                    None
81                }
82            }
83            ("db", sub) => {
84                if sub.contains("clickhouse")
85                    || sub.contains("mongodb")
86                    || sub.contains("redis")
87                    || is_legacy_activerecord(sub, db_system)
88                    || is_sql_mongodb(description, db_system)
89                {
90                    None
91                // spans coming from CoreData need to be scrubbed differently.
92                } else if span_origin == Some("auto.db.core_data") {
93                    scrub_core_data(description)
94                } else if sub.contains("prisma") {
95                    // We're not able to extract the exact query ran.
96                    // The description will only contain the entity queried and
97                    // the query type ("User find" for example).
98                    Some(description.to_owned())
99                } else if span_origin == Some("auto.db.supabase")
100                    && description.starts_with("from(")
101                {
102                    // The description only contains the table name, e.g. `"from(users)`.
103                    // In the future, we might want to parse `data.query` as well.
104                    // See https://github.com/supabase-community/sentry-integration-js/blob/master/index.js#L259
105                    scrub_supabase(description)
106                } else {
107                    let (scrubbed, mode) = sql::scrub_queries(db_system, description);
108                    if let sql::Mode::Parsed(ast) = mode {
109                        parsed_sql = Some(ast);
110                    }
111                    scrubbed
112                }
113            }
114            ("resource", ty) => scrub_resource(ty, description),
115            ("ai", sub) => match sub.split_once('.').unwrap_or((sub, "")) {
116                ("run" | "pipeline", _) => {
117                    // ai.run.* and ai.pipeline.* are low cardinality (<100 per org) and describe
118                    // the names of nodes of an AI pipeline.
119                    Some(description.to_owned())
120                }
121                _ => None,
122            },
123            ("ui", "load") => {
124                // `ui.load` spans contain component names like `ListAppViewController`, so
125                // they _should_ be low-cardinality.
126                Some(description.to_owned())
127            }
128            ("ui", sub) if sub.starts_with("interaction.") || sub.starts_with("react.") => data
129                .and_then(|data| data.ui_component_name.value())
130                .and_then(|value| value.as_str())
131                .map(String::from),
132            ("app", _) => {
133                // `app.*` has static descriptions, like `Cold Start`
134                // or `Pre Runtime Init`.
135                // They are low-cardinality.
136                Some(description.to_owned())
137            }
138            ("contentprovider", "load") => {
139                // `contentprovider.load` spans contain paths of third party framework components
140                // and their onCreate method such as
141                // `io.sentry.android.core.SentryPerformanceProvider.onCreate`, which
142                // _should_ be low-cardinality, on the order of 10s per project.
143                Some(description.to_owned())
144            }
145            ("application", "load") => {
146                // `application.load` spans contain paths of app components and their
147                // onCreate method such as
148                // `io.sentry.samples.android.MyApplication.onCreate`, which _should_ be
149                // low-cardinality.
150                Some(description.to_owned())
151            }
152            ("activity", "load") => {
153                // `activity.load` spans contain paths of app components and their onCreate/onStart
154                // method such as `io.sentry.samples.android.MainActivity.onCreate`, which
155                // _should_ be low-cardinality, less than 10 per project.
156                Some(description.to_owned())
157            }
158            ("file", _) => scrub_file(description),
159            ("function", _) => scrub_function(description),
160            _ => None,
161        });
162    (scrubbed_description, parsed_sql)
163}
164
165/// A span declares `op: db.sql.query`, but contains mongodb.
166fn is_sql_mongodb(description: &str, db_system: Option<&str>) -> bool {
167    description.contains("\"$")
168        || description.contains("({")
169        || description.contains("[{")
170        || description.starts_with('{')
171        || db_system == Some("mongodb")
172}
173
174/// We are unable to parse active record when we do not know which database is being used.
175fn is_legacy_activerecord(sub_op: &str, db_system: Option<&str>) -> bool {
176    db_system.is_none() && (sub_op.contains("active_record") || sub_op.contains("activerecord"))
177}
178
179fn scrub_core_data(string: &str) -> Option<String> {
180    match DB_SQL_TRANSACTION_CORE_DATA_REGEX.replace_all(string, "*") {
181        Cow::Owned(scrubbed) => Some(scrubbed),
182        Cow::Borrowed(_) => None,
183    }
184}
185
186fn scrub_supabase(string: &str) -> Option<String> {
187    Some(DB_SUPABASE_REGEX.replace_all(string, "{%s}").into())
188}
189
190fn scrub_http(string: &str, allow_list: &[String]) -> Option<String> {
191    let (method, url) = string.split_once(' ')?;
192    if !HTTP_METHOD_EXTRACTOR_REGEX.is_match(method) {
193        return None;
194    };
195
196    if url.starts_with("data:image/") {
197        return Some(format!("{method} data:image/*"));
198    }
199
200    let scrubbed = match Url::parse(url) {
201        Ok(url) => {
202            let scheme = url.scheme();
203            let scrubbed_host = url.host().map(|host| scrub_host(host, allow_list));
204            let domain = concatenate_host_and_port(scrubbed_host.as_deref(), url.port());
205
206            format!("{method} {scheme}://{domain}")
207        }
208        Err(_) => {
209            format!("{method} *")
210        }
211    };
212
213    Some(scrubbed)
214}
215
216fn scrub_file(description: &str) -> Option<String> {
217    let filename = match description.split_once(' ') {
218        Some((filename, _)) => filename,
219        _ => description,
220    };
221    match Path::new(filename).extension() {
222        Some(extension) => {
223            let ext = scrub_resource_file_extension(extension.to_str()?);
224            if ext != "*" {
225                Some(format!("*.{ext}"))
226            } else {
227                Some("*".to_string())
228            }
229        }
230        _ => Some("*".to_owned()),
231    }
232}
233
234/// Scrub a [`Host`] object.
235///
236/// Domain names are run through a scrubber. All IP addresses except well known ones are replaced with a scrubbed variant.
237/// Returns the scrubbed value as a string.
238///
239/// # Examples
240///
241/// ```
242/// use url::{Host, Url};
243/// use std::net::{Ipv4Addr, Ipv6Addr};
244/// use relay_event_normalization::span::description::scrub_host;
245///
246/// assert_eq!(scrub_host(Host::Domain("foo.bar.baz"), &[]), "*.bar.baz");
247/// assert_eq!(scrub_host(Host::Ipv4(Ipv4Addr::LOCALHOST), &[]), "127.0.0.1");
248/// assert_eq!(scrub_host(Host::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), &[String::from("8.8.8.8")]), "8.8.8.8");
249/// ```
250pub fn scrub_host<'a>(host: Host<&'a str>, allow_list: &'a [String]) -> Cow<'a, str> {
251    let allow_list: Vec<_> = allow_list
252        .iter()
253        .map(|origin| origin.as_str().into())
254        .collect();
255
256    if matches_any_origin(Some(host.to_string().as_str()), &allow_list) {
257        return host.to_string().into();
258    }
259
260    match host {
261        Host::Ipv4(ip) => Cow::Borrowed(scrub_ipv4(ip)),
262        Host::Ipv6(ip) => Cow::Borrowed(scrub_ipv6(ip)),
263        Host::Domain(domain) => scrub_domain_name(domain),
264    }
265}
266
267/// Scrub an IPv4 address.
268///
269/// Allow well-known IPs like loopback, and fully scrub out all other IPs.
270/// Returns the scrubbed value as a string.
271///
272/// # Examples
273///
274/// ```
275/// use std::net::Ipv4Addr;
276/// use relay_event_normalization::span::description::{scrub_ipv4};
277///
278/// assert_eq!(scrub_ipv4(Ipv4Addr::LOCALHOST), "127.0.0.1");
279/// assert_eq!(scrub_ipv4(Ipv4Addr::new(8, 8, 8, 8)), "*.*.*.*");
280/// ```
281pub fn scrub_ipv4(ip: Ipv4Addr) -> &'static str {
282    match ip {
283        Ipv4Addr::LOCALHOST => "127.0.0.1",
284        _ => "*.*.*.*",
285    }
286}
287
288/// Scrub an IPv6 address.
289///
290/// # Examples
291///
292/// ```
293/// use std::net::Ipv6Addr;
294/// use relay_event_normalization::span::description::{scrub_ipv6};
295///
296/// assert_eq!(scrub_ipv6(Ipv6Addr::LOCALHOST), "::1");
297/// assert_eq!(scrub_ipv6(Ipv6Addr::new(8, 8, 8, 8, 8, 8, 8, 8)), "*:*:*:*:*:*:*:*");
298/// ```
299pub fn scrub_ipv6(ip: Ipv6Addr) -> &'static str {
300    match ip {
301        Ipv6Addr::LOCALHOST => "::1",
302        _ => "*:*:*:*:*:*:*:*",
303    }
304}
305
306/// Sanitize a qualified domain string.
307///
308/// Replace all but the last two segments with asterisks.
309/// Returns a string. In cases where the string is not domain-like, returns the original string.
310///
311/// # Examples
312///
313/// ```
314/// use relay_event_normalization::span::description::scrub_domain_name;
315///
316/// assert_eq!(scrub_domain_name("my.domain.com"), "*.domain.com");
317/// assert_eq!(scrub_domain_name("data.bbc.co.uk"), "*.bbc.co.uk");
318/// assert_eq!(scrub_domain_name("hello world"), "hello world");
319/// ```
320pub fn scrub_domain_name(domain: &str) -> Cow<'_, str> {
321    if DOMAIN_ALLOW_LIST.contains(&domain) {
322        return Cow::Borrowed(domain);
323    }
324
325    let parsed_domain = psl::domain(domain.as_bytes());
326
327    let Some(parsed_domain) = parsed_domain else {
328        // If parsing fails, return the original string
329        return Cow::Borrowed(domain);
330    };
331
332    let suffix = parsed_domain.suffix().as_bytes();
333    let Some(second_level_domain) = parsed_domain.as_bytes().strip_suffix(suffix) else {
334        return Cow::Borrowed(domain);
335    };
336
337    let subdomain = domain
338        .as_bytes()
339        .strip_suffix(suffix)
340        .and_then(|s| s.strip_suffix(second_level_domain));
341
342    match subdomain {
343        None | Some(b"") => Cow::Borrowed(domain),
344        Some(_subdomain) => {
345            let scrubbed = [b"*.", second_level_domain, suffix].concat();
346            match String::from_utf8(scrubbed) {
347                Ok(s) => Cow::Owned(s),
348                Err(_) => Cow::Borrowed(domain),
349            }
350        }
351    }
352}
353
354/// Concatenate an optional host and an optional port.
355///
356/// Returns either a host + port combination, or the host. Never returns just the port.
357///
358/// # Examples
359///
360/// ```
361/// use relay_event_normalization::span::description::concatenate_host_and_port;
362///
363/// assert_eq!(concatenate_host_and_port(None, None), "");
364/// assert_eq!(concatenate_host_and_port(Some("my.domain.com"), None), "my.domain.com");
365/// assert_eq!(concatenate_host_and_port(Some("my.domain.com"), Some(1919)), "my.domain.com:1919");
366/// ```
367pub fn concatenate_host_and_port(host: Option<&str>, port: Option<u16>) -> Cow<str> {
368    match (host, port) {
369        (None, _) => Cow::Borrowed(""),
370        (Some(host), None) => Cow::Borrowed(host),
371        (Some(host), Some(port)) => Cow::Owned(format!("{host}:{port}")),
372    }
373}
374
375fn scrub_redis_keys(string: &str) -> Option<String> {
376    let string = string.trim();
377    Some(match matching_redis_command(string) {
378        Some(command) => {
379            let mut command = command.to_uppercase();
380            match string.get(command.len()..) {
381                None | Some("") => command,
382                Some(_other) => {
383                    command.push_str(" *");
384                    command
385                }
386            }
387        }
388        None => "*".to_owned(),
389    })
390}
391
392enum UrlType {
393    /// A full URL including scheme and domain.
394    Full,
395    /// Missing domain, starts with `/`.
396    Absolute,
397    /// Missing domain, does not start with `/`.
398    Relative,
399}
400
401/// Scrubber for spans with `span.op` "resource.*".
402fn scrub_resource(resource_type: &str, string: &str) -> Option<String> {
403    let (url, ty) = match Url::parse(string) {
404        Ok(url) => (url, UrlType::Full),
405        Err(url::ParseError::RelativeUrlWithoutBase) => {
406            // Try again, with base URL
407            match Url::options().base_url(Some(&DUMMY_BASE_URL)).parse(string) {
408                Ok(url) => (
409                    url,
410                    if string.starts_with('/') {
411                        UrlType::Absolute
412                    } else {
413                        UrlType::Relative
414                    },
415                ),
416                Err(_) => return None,
417            }
418        }
419        Err(_) => {
420            return None;
421        }
422    };
423
424    let formatted = match url.scheme() {
425        "data" => match url.path().split_once(';') {
426            Some((ty, _data)) => format!("data:{ty}"),
427            None => "data:*/*".to_owned(),
428        },
429        "chrome-extension" | "moz-extension" | "ms-browser-extension" => {
430            return Some("browser-extension://*".to_owned());
431        }
432        scheme => {
433            let scrubbed_host = url.host().map(|host| scrub_host(host, &[]));
434            let domain = concatenate_host_and_port(scrubbed_host.as_deref(), url.port());
435
436            let segment_count = url.path_segments().map(|s| s.count()).unwrap_or_default();
437            let mut output_segments = vec![];
438            for (i, segment) in url.path_segments().into_iter().flatten().enumerate() {
439                if i + 1 == segment_count {
440                    break;
441                }
442                if COMMON_PATH_SEGMENTS.contains(segment) {
443                    output_segments.push(segment);
444                } else if output_segments.last().is_none_or(|s| *s != "*") {
445                    // only one asterisk
446                    output_segments.push("*");
447                }
448            }
449
450            let segments = output_segments.join("/");
451
452            let last_segment = url
453                .path_segments()
454                .and_then(|mut s| s.next_back())
455                .unwrap_or_default();
456            let last_segment = scrub_resource_filename(resource_type, last_segment);
457
458            if segments.is_empty() {
459                format!("{scheme}://{domain}/{last_segment}")
460            } else {
461                format!("{scheme}://{domain}/{segments}/{last_segment}")
462            }
463        }
464    };
465
466    // Remove previously inserted dummy URL if necessary:
467    let formatted = match ty {
468        UrlType::Full => formatted,
469        UrlType::Absolute => formatted.replace("http://replace_me", ""),
470        UrlType::Relative => formatted.replace("http://replace_me/", ""),
471    };
472
473    Some(formatted)
474}
475
476fn scrub_resource_filename<'a>(ty: &str, path: &'a str) -> Cow<'a, str> {
477    if path.is_empty() {
478        return Cow::Borrowed("");
479    }
480    let (mut basename, mut extension) = path.rsplit_once('.').unwrap_or((path, ""));
481    if extension.contains('/') {
482        // Not really an extension
483        basename = path;
484        extension = "";
485    }
486
487    let extension = scrub_resource_file_extension(extension);
488
489    let basename = if ty == "img" {
490        Cow::Borrowed("*")
491    } else {
492        scrub_resource_segment(basename)
493    };
494
495    if extension.is_empty() {
496        basename
497    } else {
498        let mut filename = basename.to_string();
499        filename.push('.');
500        filename.push_str(extension);
501        Cow::Owned(filename)
502    }
503}
504
505fn scrub_resource_segment(segment: &str) -> Cow<str> {
506    let segment = RESOURCE_NORMALIZER_REGEX.replace_all(segment, "$pre*$post");
507
508    // Crude heuristic: treat long segments as idendifiers.
509    if segment.len() > MAX_SEGMENT_LENGTH {
510        return Cow::Borrowed("*");
511    }
512
513    let mut all_alphabetic = true;
514    let mut found_uppercase = false;
515
516    // Do not accept segments with special characters.
517    for char in segment.chars() {
518        if !char.is_ascii_alphabetic() {
519            all_alphabetic = false;
520        }
521        if char.is_ascii_uppercase() {
522            found_uppercase = true;
523        }
524        if char.is_numeric() || "&%#=+@".contains(char) {
525            return Cow::Borrowed("*");
526        };
527    }
528
529    if all_alphabetic && found_uppercase {
530        // Assume random string identifier.
531        return Cow::Borrowed("*");
532    }
533
534    segment
535}
536
537fn scrub_resource_file_extension(mut extension: &str) -> &str {
538    // Only accept short, clean file extensions.
539    let mut digits = 0;
540    for (i, byte) in extension.bytes().enumerate() {
541        if byte.is_ascii_digit() {
542            digits += 1;
543        }
544        if digits > 1 {
545            // Allow extensions like `.mp4`
546            return "*";
547        }
548        if !byte.is_ascii_alphanumeric() {
549            extension = &extension[..i];
550            break;
551        }
552    }
553
554    if extension.len() > MAX_EXTENSION_LENGTH {
555        extension = "*";
556    }
557
558    extension
559}
560
561fn scrub_function(string: &str) -> Option<String> {
562    Some(FUNCTION_NORMALIZER_REGEX.replace_all(string, "*").into())
563}
564
565fn scrub_mongodb_query(query: &str, command: &str, collection: &str) -> Option<String> {
566    let mut query: Value = serde_json::from_str(query).ok()?;
567
568    let root = query.as_object_mut()?;
569
570    // Buffers are unnecessary noise so the entire key-value pair should be removed
571    root.remove("buffer");
572
573    for value in root.values_mut() {
574        scrub_mongodb_visit_node(value, 3);
575    }
576
577    let scrubbed_collection_name =
578        if let Cow::Owned(s) = TABLE_NAME_REGEX.replace_all(collection, "{%s}") {
579            s
580        } else {
581            collection.to_owned()
582        };
583    root.insert(command.to_owned(), Value::String(scrubbed_collection_name));
584
585    Some(query.to_string())
586}
587
588fn scrub_mongodb_visit_node(value: &mut Value, recursion_limit: usize) {
589    if recursion_limit == 0 {
590        match value {
591            Value::String(str) => {
592                str.clear();
593                str.push('?');
594            }
595            value => *value = Value::String("?".to_owned()),
596        }
597        return;
598    }
599
600    match value {
601        Value::Object(map) => {
602            for value in map.values_mut() {
603                scrub_mongodb_visit_node(value, recursion_limit - 1);
604            }
605        }
606        Value::Array(arr) => {
607            arr.clear();
608            arr.push(Value::String("...".to_owned()));
609        }
610        Value::String(str) => {
611            str.clear();
612            str.push('?');
613        }
614        value => *value = Value::String("?".to_owned()),
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use relay_protocol::Annotated;
622    use similar_asserts::assert_eq;
623
624    macro_rules! span_description_test {
625        // Tests the scrubbed span description for the given op.
626
627        // Same output and input means the input was already scrubbed.
628        // An empty output `""` means the input wasn't scrubbed and Relay didn't scrub it.
629        ($name:ident, $description_in:expr, $op_in:literal, $expected:literal) => {
630            #[test]
631            fn $name() {
632                let json = format!(
633                    r#"
634                    {{
635                        "description": "",
636                        "span_id": "bd2eb23da2beb459",
637                        "start_timestamp": 1597976393.4619668,
638                        "timestamp": 1597976393.4718769,
639                        "trace_id": "ff62a8b040f340bda5d830223def1d81",
640                        "op": "{}"
641                    }}
642                "#,
643                    $op_in
644                );
645
646                let mut span = Annotated::<Span>::from_json(&json).unwrap();
647                span.value_mut()
648                    .as_mut()
649                    .unwrap()
650                    .description
651                    .set_value(Some($description_in.into()));
652
653                let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
654
655                if $expected == "" {
656                    assert!(scrubbed.0.is_none());
657                } else {
658                    assert_eq!($expected, scrubbed.0.unwrap());
659                }
660            }
661        };
662    }
663
664    macro_rules! span_description_test_with_lowercase {
665        ($name:ident, $name2:ident, $description_in:expr, $op_in:literal, $expected:literal) => {
666            span_description_test!($name, $description_in, $op_in, $expected);
667            span_description_test!($name2, ($description_in).to_lowercase(), $op_in, $expected);
668        };
669    }
670
671    span_description_test!(empty, "", "http.client", "");
672
673    span_description_test!(
674        only_domain,
675        "GET http://service.io",
676        "http.client",
677        "GET http://service.io"
678    );
679
680    span_description_test!(
681        only_urllike_on_http_ops,
682        "GET https://www.service.io/resources/01234",
683        "http.client",
684        "GET https://*.service.io"
685    );
686
687    span_description_test!(
688        path_ids_end,
689        "GET https://www.service.io/resources/01234",
690        "http.client",
691        "GET https://*.service.io"
692    );
693
694    span_description_test!(
695        path_ids_middle,
696        "GET https://www.service.io/resources/01234/details",
697        "http.client",
698        "GET https://*.service.io"
699    );
700
701    span_description_test!(
702        path_multiple_ids,
703        "GET https://www.service.io/users/01234-qwerty/settings/98765-adfghj",
704        "http.client",
705        "GET https://*.service.io"
706    );
707
708    span_description_test!(
709        localhost,
710        "GET https://localhost/data",
711        "http.client",
712        "GET https://localhost"
713    );
714
715    span_description_test!(
716        loopback,
717        "GET https://127.0.0.1/data",
718        "http.client",
719        "GET https://127.0.0.1"
720    );
721
722    span_description_test!(
723        ip_address,
724        "GET https://8.8.8.8/data",
725        "http.client",
726        "GET https://*.*.*.*"
727    );
728
729    span_description_test!(
730        path_md5_hashes,
731        "GET /clients/563712f9722fb0996ac8f3905b40786f/project/01234",
732        "http.client",
733        "GET *"
734    );
735
736    span_description_test!(
737        path_sha_hashes,
738        "GET /clients/403926033d001b5279df37cbbe5287b7c7c267fa/project/01234",
739        "http.client",
740        "GET *"
741    );
742
743    span_description_test!(
744        hex,
745        "GET /shop/de/f43/beef/3D6/my-beef",
746        "http.client",
747        "GET *"
748    );
749
750    span_description_test!(
751        path_uuids,
752        "GET /clients/8ff81d74-606d-4c75-ac5e-cee65cbbc866/project/01234",
753        "http.client",
754        "GET *"
755    );
756
757    span_description_test!(
758        data_images,
759        "GET data:image/png;base64,drtfghaksjfdhaeh/blah/blah/blah",
760        "http.client",
761        "GET data:image/*"
762    );
763
764    span_description_test!(
765        simple_cctld,
766        "GET http://bbc.co.uk",
767        "http.client",
768        "GET http://bbc.co.uk"
769    );
770
771    span_description_test!(
772        longer_cctld,
773        "GET http://www.radio1.bbc.co.uk",
774        "http.client",
775        "GET http://*.bbc.co.uk"
776    );
777
778    span_description_test!(
779        complicated_tld,
780        "GET https://application.www.xn--85x722f.xn--55qx5d.cn",
781        "http.client",
782        "GET https://*.xn--85x722f.xn--55qx5d.cn"
783    );
784
785    span_description_test!(
786        only_dblike_on_db_ops,
787        "SELECT count() FROM table WHERE id IN (%s, %s)",
788        "http.client",
789        ""
790    );
791
792    span_description_test_with_lowercase!(
793        cache,
794        cache_lower,
795        "GET abc:12:{def}:{34}:{fg56}:EAB38:zookeeper",
796        "cache.get_item",
797        "GET *"
798    );
799
800    span_description_test_with_lowercase!(
801        redis_set,
802        redis_set_lower,
803        "SET mykey myvalue",
804        "db.redis",
805        "SET *"
806    );
807
808    span_description_test_with_lowercase!(
809        redis_set_quoted,
810        redis_set_quoted_lower,
811        r#"SET mykey 'multi: part, value'"#,
812        "db.redis",
813        "SET *"
814    );
815
816    span_description_test_with_lowercase!(
817        redis_whitespace,
818        redis_whitespace_lower,
819        " GET  asdf:123",
820        "db.redis",
821        "GET *"
822    );
823
824    span_description_test_with_lowercase!(
825        redis_no_args,
826        redis_no_args_lower,
827        "EXEC",
828        "db.redis",
829        "EXEC"
830    );
831
832    span_description_test_with_lowercase!(
833        redis_invalid,
834        redis_invalid_lower,
835        "What a beautiful day!",
836        "db.redis",
837        "*"
838    );
839
840    span_description_test_with_lowercase!(
841        redis_long_command,
842        redis_long_command_lower,
843        "ACL SETUSER jane",
844        "db.redis",
845        "ACL SETUSER *"
846    );
847
848    span_description_test!(
849        nothing_cache,
850        "abc-dontscrubme-meneither:stillno:ohplsstop",
851        "cache.get_item",
852        "*"
853    );
854
855    span_description_test!(
856        resource_script,
857        "https://example.com/static/chunks/vendors-node_modules_somemodule_v1.2.3_mini-dist_index_js-client_dist-6c733292-f3cd-11ed-a05b-0242ac120003-0dc369dcf3d311eda05b0242ac120003.[hash].abcd1234.chunk.js-0242ac120003.map",
858        "resource.script",
859        "https://example.com/static/chunks/*.map"
860    );
861
862    span_description_test!(
863        resource_script_numeric_filename,
864        "https://example.com/static/chunks/09876543211234567890",
865        "resource.script",
866        "https://example.com/static/chunks/*"
867    );
868
869    span_description_test!(
870        resource_next_chunks,
871        "/_next/static/chunks/12345-abcdef0123456789.js",
872        "resource.script",
873        "/_next/static/chunks/*-*.js"
874    );
875
876    span_description_test!(
877        resource_next_media,
878        "/_next/static/media/Some_Font-Bold.0123abcd.woff2",
879        "resource.css",
880        "/_next/static/media/Some_Font-Bold.*.woff2"
881    );
882
883    span_description_test!(
884        resource_css,
885        "https://example.com/assets/dark_high_contrast-764fa7c8-f3cd-11ed-a05b-0242ac120003.css",
886        "resource.css",
887        "https://example.com/assets/dark_high_contrast-*.css"
888    );
889
890    span_description_test!(
891        integer_in_resource,
892        "https://example.com/assets/this_is-a_good_resource-123-scrub_me.js",
893        "resource.css",
894        "https://example.com/assets/*.js"
895    );
896
897    span_description_test!(
898        resource_query_params,
899        "/organization-avatar/123/?s=120",
900        "resource.img",
901        "/*/"
902    );
903
904    span_description_test!(
905        resource_query_params2,
906        "https://data.domain.com/data/guide123.gif?jzb=3f535634H467g5-2f256f&ct=1234567890&v=1.203.0_prod",
907        "resource.img",
908        "https://*.domain.com/data/*.gif"
909    );
910
911    span_description_test!(
912        resource_query_params2_script,
913        "https://data.domain.com/data/guide123.js?jzb=3f535634H467g5-2f256f&ct=1234567890&v=1.203.0_prod",
914        "resource.script",
915        "https://*.domain.com/data/guide*.js"
916    );
917
918    span_description_test!(
919        resource_no_ids,
920        "https://data.domain.com/js/guide.js",
921        "resource.script",
922        "https://*.domain.com/js/guide.js"
923    );
924
925    span_description_test!(
926        resource_no_ids_img_known_segment,
927        "https://data.domain.com/data/guide.gif",
928        "resource.img",
929        "https://*.domain.com/data/*.gif"
930    );
931
932    span_description_test!(
933        resource_no_ids_img,
934        "https://data.domain.com/something/guide.gif",
935        "resource.img",
936        "https://*.domain.com/*/*.gif"
937    );
938
939    span_description_test!(
940        resource_webpack,
941        "https://domain.com/path/to/app-1f90d5.f012d11690e188c96fe6.js",
942        "resource.js",
943        "https://domain.com/*/app-*.*.js"
944    );
945
946    span_description_test!(
947        resource_vite,
948        "webroot/assets/Profile-73f6525d.js",
949        "resource.js",
950        "*/assets/Profile-*.js"
951    );
952
953    span_description_test!(
954        resource_vite_css,
955        "webroot/assets/Shop-1aff80f7.css",
956        "resource.css",
957        "*/assets/Shop-*.css"
958    );
959
960    span_description_test!(
961        chrome_extension,
962        "chrome-extension://begnopegbbhjeeiganiajffnalhlkkjb/img/assets/icon-10k.svg",
963        "resource.other",
964        "browser-extension://*"
965    );
966
967    span_description_test!(
968        urlencoded_path_segments,
969        "https://some.domain.com/embed/%2Fembed%2Fdashboards%2F20%3FSlug%3Dsomeone%*hide_title%3Dtrue",
970        "resource.iframe",
971        "https://*.domain.com/*/*"
972    );
973
974    span_description_test!(
975        random_string1,
976        "https://static.domain.com/6gezWf_qs4Wc12Nz9rpLOx2aw2k/foo-99",
977        "resource.img",
978        "https://*.domain.com/*/*"
979    );
980
981    span_description_test!(
982        random_string1_script,
983        "https://static.domain.com/6gezWf_qs4Wc12Nz9rpLOx2aw2k/foo-99",
984        "resource.script",
985        "https://*.domain.com/*/foo-*"
986    );
987
988    span_description_test!(
989        random_string2,
990        "http://domain.com/fy2XSqBMqkEm_qZZH3RrzvBTKg4/qltdXIJWTF_cuwt3uKmcwWBc1DM/z1a--BVsUI_oyUjJR12pDBcOIn5.dom.jsonp",
991        "resource.script",
992        "http://domain.com/*/*.jsonp"
993    );
994
995    span_description_test!(
996        random_string3,
997        "jkhdkkncnoglghljlkmcimlnlhkeamab/123.css",
998        "resource.link",
999        "*/*.css"
1000    );
1001
1002    span_description_test!(
1003        ui_load,
1004        "ListAppViewController",
1005        "ui.load",
1006        "ListAppViewController"
1007    );
1008
1009    span_description_test!(
1010        contentprovider_load,
1011        "io.sentry.android.core.SentryPerformanceProvider.onCreate",
1012        "contentprovider.load",
1013        "io.sentry.android.core.SentryPerformanceProvider.onCreate"
1014    );
1015
1016    span_description_test!(
1017        application_load,
1018        "io.sentry.samples.android.MyApplication.onCreate",
1019        "application.load",
1020        "io.sentry.samples.android.MyApplication.onCreate"
1021    );
1022
1023    span_description_test!(
1024        activity_load,
1025        "io.sentry.samples.android.MainActivity.onCreate",
1026        "activity.load",
1027        "io.sentry.samples.android.MainActivity.onCreate"
1028    );
1029
1030    span_description_test!(
1031        span_description_file_write_keep_extension_only,
1032        "data.data (42 KB)",
1033        "file.write",
1034        "*.data"
1035    );
1036
1037    span_description_test!(
1038        span_description_file_read_keep_extension_only,
1039        "Info.plist",
1040        "file.read",
1041        "*.plist"
1042    );
1043
1044    span_description_test!(
1045        span_description_file_with_no_extension,
1046        "somefilenamewithnoextension",
1047        "file.read",
1048        "*"
1049    );
1050
1051    span_description_test!(
1052        span_description_file_extension_with_numbers_only,
1053        "backup.2024041101",
1054        "file.read",
1055        "*"
1056    );
1057
1058    span_description_test!(
1059        resource_url_with_fragment,
1060        "https://data.domain.com/data/guide123.gif#url=someotherurl",
1061        "resource.img",
1062        "https://*.domain.com/data/*.gif"
1063    );
1064
1065    span_description_test!(
1066        resource_script_with_no_extension,
1067        "https://www.domain.com/page?id=1234567890",
1068        "resource.script",
1069        "https://*.domain.com/page"
1070    );
1071
1072    span_description_test!(
1073        resource_script_with_no_domain,
1074        "/page.js?action=name",
1075        "resource.script",
1076        "/page.js"
1077    );
1078
1079    span_description_test!(
1080        resource_script_with_no_domain_no_extension,
1081        "/page?action=name",
1082        "resource.script",
1083        "/page"
1084    );
1085
1086    span_description_test!(
1087        resource_script_with_long_extension,
1088        "/path/to/file.thisismycustomfileextension2000",
1089        "resource.script",
1090        "/*/file.*"
1091    );
1092
1093    span_description_test!(
1094        resource_script_with_long_suffix,
1095        "/path/to/file.js~ri~some-_-1,,thing-_-words%2Fhere~ri~",
1096        "resource.script",
1097        "/*/file.js"
1098    );
1099
1100    span_description_test!(
1101        resource_script_with_tilde_extension,
1102        "/path/to/file.~~",
1103        "resource.script",
1104        "/*/file"
1105    );
1106
1107    span_description_test!(
1108        resource_img_extension,
1109        "http://domain.com/something.123",
1110        "resource.img",
1111        "http://domain.com/*.*"
1112    );
1113
1114    span_description_test!(
1115        resource_img_embedded,
1116        "data:image/svg+xml;base64,PHN2ZyB4bW",
1117        "resource.img",
1118        "data:image/svg+xml"
1119    );
1120
1121    span_description_test!(
1122        db_category_with_mongodb_query,
1123        "find({some_id:1234567890},{limit:100})",
1124        "db",
1125        ""
1126    );
1127
1128    span_description_test!(db_category_with_not_sql, "{someField:someValue}", "db", "");
1129
1130    span_description_test!(
1131        resource_img_semi_colon,
1132        "http://www.foo.com/path/to/resource;param1=test;param2=ing",
1133        "resource.img",
1134        "http://*.foo.com/*/*"
1135    );
1136
1137    span_description_test!(
1138        resource_img_comma_with_extension,
1139        "https://example.org/p/fit=cover,width=150,height=150,format=auto,quality=90/media/photosV2/weird-stuff-123-234-456.jpg",
1140        "resource.img",
1141        "https://example.org/*/media/*/*.jpg"
1142    );
1143
1144    span_description_test!(
1145        resource_script_comma_with_extension,
1146        "https://example.org/p/fit=cover,width=150,height=150,format=auto,quality=90/media/photosV2/weird-stuff-123-234-456.js",
1147        "resource.script",
1148        "https://example.org/*/media/*/weird-stuff-*-*-*.js"
1149    );
1150
1151    span_description_test!(
1152        resource_img_path_with_comma,
1153        "/help/purchase-details/1,*,0&fmt=webp&qlt=*,1&fit=constrain,0&op_sharpen=0&resMode=sharp2&iccEmbed=0&printRes=*",
1154        "resource.img",
1155        "/*/*"
1156    );
1157
1158    span_description_test!(
1159        resource_script_path_with_comma,
1160        "/help/purchase-details/1,*,0&fmt=webp&qlt=*,1&fit=constrain,0&op_sharpen=0&resMode=sharp2&iccEmbed=0&printRes=*",
1161        "resource.script",
1162        "/*/*"
1163    );
1164
1165    span_description_test!(
1166        resource_script_random_path_only,
1167        "/ERs-sUsu3/wd4/LyMTWg/Ot1Om4m8cu3p7a/QkJWAQ/FSYL/GBlxb3kB",
1168        "resource.script",
1169        "/*/*"
1170    );
1171
1172    span_description_test!(
1173        resource_script_normalize_domain,
1174        "https://sub.sub.sub.domain.com/resource.js",
1175        "resource.script",
1176        "https://*.domain.com/resource.js"
1177    );
1178
1179    span_description_test!(
1180        resource_script_extension_in_segment,
1181        "https://domain.com/foo.bar/resource.js",
1182        "resource.script",
1183        "https://domain.com/*/resource.js"
1184    );
1185
1186    span_description_test!(
1187        resource_script_missing_scheme,
1188        "domain.com/foo.bar/resource.js",
1189        "resource.script",
1190        "*/resource.js"
1191    );
1192
1193    span_description_test!(
1194        resource_script_missing_scheme_integer_id,
1195        "domain.com/zero-length-00",
1196        "resource.script",
1197        "*/zero-length-*"
1198    );
1199
1200    span_description_test!(db_prisma, "User find", "db.sql.prisma", "User find");
1201
1202    span_description_test!(
1203        function_python,
1204        "sentry.event_manager.assign_event_to_group",
1205        "function",
1206        "sentry.event_manager.assign_event_to_group"
1207    );
1208
1209    span_description_test!(
1210        function_rust,
1211        "symbolicator_native::symbolication::symbolicate::symbolicate",
1212        "function",
1213        "symbolicator_native::symbolication::symbolicate::symbolicate"
1214    );
1215
1216    span_description_test!(
1217        function_with_hex,
1218        "symbolicator_native::symbolication::symbolicate::deadbeef",
1219        "function",
1220        "symbolicator_native::symbolication::symbolicate::*"
1221    );
1222
1223    span_description_test!(
1224        function_with_uuid,
1225        "symbolicator_native::symbolication::fb37f08422034ee985e9fc553ef27e6e::symbolicate",
1226        "function",
1227        "symbolicator_native::symbolication::*::symbolicate"
1228    );
1229
1230    #[test]
1231    fn informed_sql_parser() {
1232        let json = r#"
1233            {
1234                "description": "SELECT \"not an identifier\"",
1235                "span_id": "bd2eb23da2beb459",
1236                "start_timestamp": 1597976393.4619668,
1237                "timestamp": 1597976393.4718769,
1238                "trace_id": "ff62a8b040f340bda5d830223def1d81",
1239                "op": "db",
1240                "data": {"db.system": "mysql"}
1241            }
1242        "#;
1243
1244        let mut span = Annotated::<Span>::from_json(json).unwrap();
1245        let span = span.value_mut().as_mut().unwrap();
1246        let scrubbed = scrub_span_description(span, &[]);
1247        assert_eq!(scrubbed.0.as_deref(), Some("SELECT %s"));
1248    }
1249
1250    #[test]
1251    fn active_record() {
1252        let json = r#"{
1253            "description": "/*some comment `my_function'*/ SELECT `a` FROM `b`",
1254            "op": "db.sql.activerecord"
1255        }"#;
1256
1257        let mut span = Annotated::<Span>::from_json(json).unwrap();
1258
1259        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1260
1261        // When db.system is missing, no scrubbed description (i.e. no group) is set.
1262        assert!(scrubbed.0.is_none());
1263    }
1264
1265    #[test]
1266    fn active_record_with_db_system() {
1267        let json = r#"{
1268            "description": "/*some comment `my_function'*/ SELECT `a` FROM `b`",
1269            "op": "db.sql.activerecord",
1270            "data": {
1271                "db.system": "mysql"
1272            }
1273        }"#;
1274
1275        let mut span = Annotated::<Span>::from_json(json).unwrap();
1276
1277        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1278
1279        // Can be scrubbed with db system.
1280        assert_eq!(scrubbed.0.as_deref(), Some("SELECT a FROM b"));
1281    }
1282
1283    #[test]
1284    fn redis_with_db_system() {
1285        let json = r#"{
1286            "description": "del myveryrandomkey:123Xalsdkxfhn",
1287            "op": "db",
1288            "data": {
1289                "db.system": "redis"
1290            }
1291        }"#;
1292
1293        let mut span = Annotated::<Span>::from_json(json).unwrap();
1294
1295        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1296
1297        assert_eq!(scrubbed.0.as_deref(), Some("DEL *"));
1298    }
1299
1300    #[test]
1301    fn core_data() {
1302        let json = r#"{
1303            "description": "INSERTED 1 'UAEventData'",
1304            "op": "db.sql.transaction",
1305            "origin": "auto.db.core_data"
1306        }"#;
1307
1308        let mut span = Annotated::<Span>::from_json(json).unwrap();
1309
1310        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1311
1312        assert_eq!(scrubbed.0.as_deref(), Some("INSERTED * 'UAEventData'"));
1313    }
1314
1315    #[test]
1316    fn multiple_core_data() {
1317        let json = r#"{
1318            "description": "UPDATED 1 'QueuedRequest', DELETED 1 'QueuedRequest'",
1319            "op": "db.sql.transaction",
1320            "origin": "auto.db.core_data"
1321        }"#;
1322
1323        let mut span = Annotated::<Span>::from_json(json).unwrap();
1324
1325        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1326
1327        assert_eq!(
1328            scrubbed.0.as_deref(),
1329            Some("UPDATED * 'QueuedRequest', DELETED * 'QueuedRequest'")
1330        );
1331    }
1332
1333    #[test]
1334    fn mongodb_scrubbing() {
1335        let json = r#"{
1336            "description": "{\"find\": \"documents\", \"foo\": \"bar\"}",
1337            "op": "db",
1338            "data": {
1339                "db.system": "mongodb",
1340                "db.operation": "find",
1341                "db.collection.name": "documents"
1342            }
1343        }"#;
1344
1345        let mut span = Annotated::<Span>::from_json(json).unwrap();
1346
1347        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1348
1349        assert_eq!(
1350            scrubbed.0.as_deref(),
1351            Some(r#"{"find":"documents","foo":"?"}"#)
1352        )
1353    }
1354
1355    #[test]
1356    fn mongodb_with_legacy_collection_property() {
1357        let json = r#"{
1358            "description": "{\"find\": \"documents\", \"foo\": \"bar\"}",
1359            "op": "db",
1360            "data": {
1361                "db.system": "mongodb",
1362                "db.operation": "find",
1363                "db.mongodb.collection": "documents"
1364            }
1365        }"#;
1366
1367        let mut span = Annotated::<Span>::from_json(json).unwrap();
1368
1369        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1370
1371        assert_eq!(
1372            scrubbed.0.as_deref(),
1373            Some(r#"{"find":"documents","foo":"?"}"#)
1374        )
1375    }
1376
1377    #[test]
1378    fn ui_interaction_with_component_name() {
1379        let json = r#"{
1380            "description": "input.app-asdfasfg.asdfasdf[type=\"range\"][name=\"replay-timeline\"]",
1381            "op": "ui.interaction.click",
1382            "data": {
1383                "ui.component_name": "my-component-name"
1384            }
1385        }"#;
1386
1387        let mut span = Annotated::<Span>::from_json(json).unwrap();
1388
1389        let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1390
1391        // Can be scrubbed with db system.
1392        assert_eq!(scrubbed.0.as_deref(), Some("my-component-name"));
1393    }
1394
1395    #[test]
1396    fn scrub_allowed_host() {
1397        let examples = [
1398            (
1399                "https://foo.bar.internal/api/v1/submit",
1400                ["foo.bar.internal".to_string()],
1401                "https://foo.bar.internal",
1402            ),
1403            (
1404                "http://192.168.1.1:3000",
1405                ["192.168.1.1".to_string()],
1406                "http://192.168.1.1:3000",
1407            ),
1408            (
1409                "http://[1fff:0:a88:85a3::ac1f]:8001/foo",
1410                ["[1fff:0:a88:85a3::ac1f]".to_string()],
1411                "http://[1fff:0:a88:85a3::ac1f]:8001",
1412            ),
1413        ];
1414
1415        for (url, allowed_hosts, expected) in examples {
1416            let json = format!(
1417                r#"{{
1418                    "description": "POST {}",
1419                    "span_id": "bd2eb23da2beb459",
1420                    "start_timestamp": 1597976393.4619668,
1421                    "timestamp": 1597976393.4718769,
1422                    "trace_id": "ff62a8b040f340bda5d830223def1d81",
1423                    "op": "http.client"
1424        }}
1425            "#,
1426                url,
1427            );
1428
1429            let mut span = Annotated::<Span>::from_json(&json).unwrap();
1430
1431            let scrubbed =
1432                scrub_span_description(span.value_mut().as_mut().unwrap(), &allowed_hosts);
1433
1434            assert_eq!(
1435                scrubbed.0.as_deref(),
1436                Some(format!("POST {}", expected).as_str()),
1437                "Could not match {url}"
1438            );
1439        }
1440    }
1441
1442    macro_rules! mongodb_scrubbing_test {
1443        // Tests the scrubbed description for the given mongodb query.
1444
1445        // Same output and input means the input was already scrubbed.
1446        // An empty output `""` means the input wasn't scrubbed and Relay didn't scrub it.
1447        ($name:ident, $description_in:expr, $operation_in:literal, $collection_in:literal, $expected:literal) => {
1448            #[test]
1449            fn $name() {
1450                let json = format!(
1451                    r#"
1452                    {{
1453                        "description": "",
1454                        "span_id": "bd2eb23da2beb459",
1455                        "start_timestamp": 1597976393.4619668,
1456                        "timestamp": 1597976393.4718769,
1457                        "trace_id": "ff62a8b040f340bda5d830223def1d81",
1458                        "op": "db",
1459                        "data": {{
1460                            "db.system": "mongodb",
1461                            "db.operation": {},
1462                            "db.collection.name": {}
1463                        }}
1464                    }}
1465                "#,
1466                    if $operation_in == "" {
1467                        "null".to_string()
1468                    } else {
1469                        format!("\"{}\"", $operation_in)
1470                    },
1471                    if $collection_in == "" {
1472                        "null".to_string()
1473                    } else {
1474                        format!("\"{}\"", $collection_in)
1475                    }
1476                );
1477
1478                let mut span = Annotated::<Span>::from_json(&json).unwrap();
1479                span.value_mut()
1480                    .as_mut()
1481                    .unwrap()
1482                    .description
1483                    .set_value(Some($description_in.into()));
1484
1485                let scrubbed = scrub_span_description(span.value_mut().as_mut().unwrap(), &[]);
1486
1487                if $expected == "" {
1488                    assert!(scrubbed.0.is_none());
1489                } else {
1490                    assert_eq!($expected, scrubbed.0.unwrap());
1491                }
1492            }
1493        };
1494    }
1495
1496    mongodb_scrubbing_test!(
1497        mongodb_basic_query,
1498        r#"{"find": "documents", "showRecordId": true}"#,
1499        "find",
1500        "documents",
1501        r#"{"find":"documents","showRecordId":"?"}"#
1502    );
1503
1504    mongodb_scrubbing_test!(
1505        mongodb_query_with_document_param,
1506        r#"{"find": "documents", "filter": {"foo": "bar"}}"#,
1507        "find",
1508        "documents",
1509        r#"{"filter":{"foo":"?"},"find":"documents"}"#
1510    );
1511
1512    mongodb_scrubbing_test!(
1513        mongodb_query_without_operation,
1514        r#"{"filter": {"foo": "bar"}}"#,
1515        "find",
1516        "documents",
1517        r#"{"filter":{"foo":"?"},"find":"documents"}"#
1518    );
1519
1520    mongodb_scrubbing_test!(
1521        mongodb_without_collection_in_data,
1522        r#"{"find": "documents", "showRecordId": true}"#,
1523        "find",
1524        "",
1525        ""
1526    );
1527
1528    mongodb_scrubbing_test!(
1529        mongodb_without_operation_in_data,
1530        r#"{"find": "documents", "showRecordId": true}"#,
1531        "",
1532        "documents",
1533        ""
1534    );
1535
1536    mongodb_scrubbing_test!(
1537        mongodb_max_depth,
1538        r#"{"update": "coll", "updates": {"q": {"_id": "1"}, "u": {"$set": {"foo": {"bar": {"baz": "quux"}}}}}}"#,
1539        "update",
1540        "coll",
1541        r#"{"update":"coll","updates":{"q":{"_id":"?"},"u":{"$set":{"foo":"?"}}}}"#
1542    );
1543
1544    mongodb_scrubbing_test!(
1545        mongodb_identifier_in_collection,
1546        r#"{"find": "documents001", "showRecordId": true}"#,
1547        "find",
1548        "documents001",
1549        r#"{"find":"documents{%s}","showRecordId":"?"}"#
1550    );
1551
1552    mongodb_scrubbing_test!(
1553        mongodb_query_with_array,
1554        r#"{"insert": "documents", "documents": [{"foo": "bar"}, {"baz": "quux"}, {"qux": "quuz"}]}"#,
1555        "insert",
1556        "documents",
1557        r#"{"documents":["..."],"insert":"documents"}"#
1558    );
1559
1560    mongodb_scrubbing_test!(
1561        mongodb_query_with_buffer,
1562        r#"{"insert": "documents", "buffer": {"0": "a", "1": "b", "2": "c"}, "documents": [{"foo": "bar"}]}"#,
1563        "insert",
1564        "documents",
1565        r#"{"documents":["..."],"insert":"documents"}"#
1566    );
1567}