Skip to main content

relay_event_normalization/normalize/span/description/
mod.rs

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