1mod 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
31static DUMMY_BASE_URL: LazyLock<Url> = LazyLock::new(|| "http://replace_me".parse().unwrap());
33
34const MAX_SEGMENT_LENGTH: usize = 25;
38
39const MAX_EXTENSION_LENGTH: usize = 10;
41
42const DOMAIN_ALLOW_LIST: &[&str] = &["localhost"];
44
45pub(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 Some(description.to_owned())
96 }
97 _ => None,
98 },
99 ("ui", "load") => {
100 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 Some(description.to_owned())
112 }
113 ("contentprovider", "load") => {
114 Some(description.to_owned())
119 }
120 ("application", "load") => {
121 Some(description.to_owned())
126 }
127 ("activity", "load") => {
128 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
140pub 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 scrub_core_data(raw_query)
171 } else if sub_op.contains("prisma") {
172 Some(raw_query.to_owned())
176 } else if span_origin == Some("auto.db.supabase") && raw_query.starts_with("from(") {
177 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
192fn 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
201fn 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
217pub 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
262pub 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
295pub fn scrub_ipv4(ip: Ipv4Addr) -> &'static str {
310 match ip {
311 Ipv4Addr::LOCALHOST => "127.0.0.1",
312 _ => "*.*.*.*",
313 }
314}
315
316pub fn scrub_ipv6(ip: Ipv6Addr) -> &'static str {
328 match ip {
329 Ipv6Addr::LOCALHOST => "::1",
330 _ => "*:*:*:*:*:*:*:*",
331 }
332}
333
334pub 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 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
382pub 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 Full,
423 Absolute,
425 Relative,
427}
428
429fn 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 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 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 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 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 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 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 return Cow::Borrowed("*");
560 }
561
562 segment
563}
564
565fn scrub_resource_file_extension(mut extension: &str) -> &str {
566 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 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 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 ($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 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 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 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 ($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}