1use std::borrow::Cow;
6use std::net::IpAddr;
7
8use chrono::{DateTime, Utc};
9use relay_common::time::UnixTimestamp;
10use relay_conventions::consts::*;
11use relay_conventions::{AttributeInfo, WriteBehavior};
12use relay_event_schema::protocol::{AttributeType, Attributes, BrowserContext, Geo};
13use relay_protocol::{Annotated, ErrorKind, Meta, Remark, RemarkType, Value};
14use relay_sampling::DynamicSamplingContext;
15use relay_spans::derive_op_for_v2_span;
16
17use crate::span::TABLE_NAME_REGEX;
18use crate::span::description::{scrub_db_query, scrub_http};
19use crate::span::tag_extraction::{
20 domain_from_scrubbed_http, domain_from_server_address, span_op_to_category,
21 sql_action_from_query, sql_tables_from_query,
22};
23use crate::{ClientHints, FromUserAgentInfo as _, RawUserAgentInfo};
24
25mod ai;
26mod size;
27#[allow(unused)]
28mod trimming;
29
30pub use self::ai::normalize_ai;
31pub use self::size::*;
32
33pub fn normalize_sentry_op(attributes: &mut Annotated<Attributes>) {
35 if attributes
36 .value()
37 .is_some_and(|attrs| attrs.contains_key(OP))
38 {
39 return;
40 }
41 let inferred_op = derive_op_for_v2_span(attributes);
42 let attrs = attributes.get_or_insert_with(Default::default);
43 attrs.insert_if_missing(OP, || inferred_op);
44}
45
46pub fn normalize_span_category(attributes: &mut Annotated<Attributes>) {
50 let Some(attributes_val) = attributes.value() else {
51 return;
52 };
53
54 if attribute_is_nonempty_string(attributes_val, SENTRY_CATEGORY) {
56 return;
57 }
58
59 if let Some(op_value) = attributes_val.get_value(OP)
61 && let Some(op_str) = op_value.as_str()
62 {
63 let op_lowercase = op_str.to_lowercase();
64 if let Some(category) = span_op_to_category(&op_lowercase) {
65 let attrs = attributes.get_or_insert_with(Default::default);
66 attrs.insert(SENTRY_CATEGORY, category.to_owned());
67 return;
68 }
69 }
70
71 let category = if attribute_is_nonempty_string(attributes_val, DB_SYSTEM_NAME) {
73 Some("db")
74 } else if attribute_is_nonempty_string(attributes_val, HTTP_REQUEST_METHOD) {
75 Some("http")
76 } else if attribute_is_nonempty_string(attributes_val, UI_COMPONENT_NAME) {
77 Some("ui")
78 } else if attribute_is_nonempty_string(attributes_val, RESOURCE_RENDER_BLOCKING_STATUS) {
79 Some("resource")
80 } else if attributes_val
81 .get_value(ORIGIN)
82 .and_then(|v| v.as_str())
83 .is_some_and(|v| v == "auto.ui.browser.metrics")
84 {
85 Some("browser")
86 } else {
87 None
88 };
89
90 if let Some(category) = category {
92 let attrs = attributes.get_or_insert_with(Default::default);
93 attrs.insert(SENTRY_CATEGORY, category.to_owned());
94 }
95}
96
97fn attribute_is_nonempty_string(attributes: &Attributes, key: &str) -> bool {
98 attributes
99 .get_value(key)
100 .and_then(|v| v.as_str())
101 .is_some_and(|s| !s.is_empty())
102}
103
104pub fn normalize_attribute_types(attributes: &mut Annotated<Attributes>) {
109 let Some(attributes) = attributes.value_mut() else {
110 return;
111 };
112
113 let attributes = attributes.0.values_mut();
114 for attribute in attributes {
115 use AttributeType::*;
116
117 let Some(inner) = attribute.value_mut() else {
118 continue;
119 };
120
121 match (&mut inner.value.ty, &mut inner.value.value) {
122 (Annotated(Some(Boolean), _), Annotated(Some(Value::Bool(_)), _)) => (),
123 (Annotated(Some(Integer), _), Annotated(Some(Value::I64(_)), _)) => (),
124 (Annotated(Some(Integer), _), Annotated(Some(Value::U64(_)), _)) => (),
125 (Annotated(Some(Double), _), Annotated(Some(Value::I64(_)), _)) => (),
126 (Annotated(Some(Double), _), Annotated(Some(Value::U64(_)), _)) => (),
127 (Annotated(Some(Double), _), Annotated(Some(Value::F64(_)), _)) => (),
128 (Annotated(Some(String), _), Annotated(Some(Value::String(_)), _)) => (),
129 (Annotated(Some(Array), _), Annotated(Some(Value::Array(arr)), _)) => {
130 if !is_supported_array(arr) {
131 let _ = attribute.value_mut().take();
132 attribute.meta_mut().add_error(ErrorKind::InvalidData);
133 }
134 }
135 (Annotated(Some(Unknown(_)), _), _) => {
141 let original = attribute.value_mut().take();
142 attribute.meta_mut().add_error(ErrorKind::InvalidData);
143 attribute.meta_mut().set_original_value(original);
144 }
145 (Annotated(Some(_), _), Annotated(Some(_), _)) => {
146 let original = attribute.value_mut().take();
147 attribute.meta_mut().add_error(ErrorKind::InvalidData);
148 attribute.meta_mut().set_original_value(original);
149 }
150 (Annotated(None, _), _) | (_, Annotated(None, _)) => {
151 let original = attribute.value_mut().take();
152 attribute.meta_mut().add_error(ErrorKind::MissingAttribute);
153 attribute.meta_mut().set_original_value(original);
154 }
155 }
156 }
157}
158
159fn is_supported_array(arr: &[Annotated<Value>]) -> bool {
163 let mut iter = arr.iter();
164
165 let Some(first) = iter.next() else {
166 return true;
168 };
169
170 let item = iter.try_fold(first, |prev, current| {
171 let r = match (prev.value(), current.value()) {
172 (None, None) => prev,
173 (None, Some(_)) => current,
174 (Some(_), None) => prev,
175 (Some(Value::String(_)), Some(Value::String(_))) => prev,
176 (Some(Value::Bool(_)), Some(Value::Bool(_))) => prev,
177 (
178 Some(Value::I64(_) | Value::U64(_) | Value::F64(_)),
180 Some(Value::I64(_) | Value::U64(_) | Value::F64(_)),
181 ) => prev,
182 (Some(_), Some(_)) => return None,
186 };
187
188 Some(r)
189 });
190
191 let Some(item) = item else {
192 return false;
194 };
195
196 matches!(
197 item.value(),
198 None | Some(
201 Value::String(_) | Value::Bool(_) | Value::I64(_) | Value::U64(_) | Value::F64(_)
202 )
203 )
204}
205
206pub fn normalize_received(attributes: &mut Annotated<Attributes>, received: DateTime<Utc>) {
208 attributes
209 .get_or_insert_with(Default::default)
210 .insert_if_missing(OBSERVED_TIMESTAMP_NANOS, || {
211 received
212 .timestamp_nanos_opt()
213 .unwrap_or_else(|| UnixTimestamp::now().as_nanos() as i64)
214 .to_string()
215 });
216}
217
218pub fn normalize_user_agent(
223 attributes: &mut Annotated<Attributes>,
224 client_user_agent: Option<&str>,
225 client_hints: ClientHints<&str>,
226) {
227 let attributes = attributes.get_or_insert_with(Default::default);
228
229 if attributes.contains_key(BROWSER_NAME) || attributes.contains_key(BROWSER_VERSION) {
230 return;
231 }
232
233 let user_agent = attributes
235 .get_value(USER_AGENT_ORIGINAL)
236 .and_then(|v| v.as_str())
237 .or(client_user_agent);
238
239 let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
240 user_agent,
241 client_hints,
242 }) else {
243 return;
244 };
245
246 attributes.insert_if_missing(BROWSER_NAME, || context.name);
247 attributes.insert_if_missing(BROWSER_VERSION, || context.version);
248}
249
250pub fn normalize_client_address(attributes: &mut Annotated<Attributes>, client_ip: Option<IpAddr>) {
258 let Some(attributes) = attributes.value_mut() else {
259 return;
260 };
261 let Some(client_ip) = client_ip else { return };
262
263 let client_address = attributes
264 .get_value(CLIENT_ADDRESS)
265 .and_then(|v| v.as_str());
266
267 if client_address == Some("{{auto}}") {
268 attributes.insert(CLIENT_ADDRESS, client_ip.to_string());
269 }
270}
271
272pub fn normalize_user_geo(
277 attributes: &mut Annotated<Attributes>,
278 info: impl FnOnce() -> Option<Geo>,
279) {
280 let attributes = attributes.get_or_insert_with(Default::default);
281
282 if [
283 USER_GEO_COUNTRY_CODE,
284 USER_GEO_CITY,
285 USER_GEO_SUBDIVISION,
286 USER_GEO_REGION,
287 ]
288 .into_iter()
289 .any(|a| attributes.contains_key(a))
290 {
291 return;
292 }
293
294 let Some(geo) = info() else {
295 return;
296 };
297
298 attributes.insert_if_missing(USER_GEO_COUNTRY_CODE, || geo.country_code);
299 attributes.insert_if_missing(USER_GEO_CITY, || geo.city);
300 attributes.insert_if_missing(USER_GEO_SUBDIVISION, || geo.subdivision);
301 attributes.insert_if_missing(USER_GEO_REGION, || geo.region);
302}
303
304pub fn normalize_dsc(attributes: &mut Annotated<Attributes>, dsc: Option<&DynamicSamplingContext>) {
306 let Some(dsc) = dsc else { return };
307
308 let attributes = attributes.get_or_insert_with(Default::default);
309
310 if attributes.contains_key(DSC_TRACE_ID) {
312 return;
313 }
314
315 attributes.insert(DSC_TRACE_ID, dsc.trace_id.to_string());
316 attributes.insert(DSC_PUBLIC_KEY, dsc.public_key.to_string());
317 if let Some(release) = &dsc.release {
318 attributes.insert(DSC_RELEASE, release.clone());
319 }
320 if let Some(environment) = &dsc.environment {
321 attributes.insert(DSC_ENVIRONMENT, environment.clone());
322 }
323 if let Some(transaction) = &dsc.transaction {
324 attributes.insert(DSC_TRANSACTION, transaction.clone());
325 }
326 if let Some(sample_rate) = dsc.sample_rate {
327 attributes.insert(DSC_SAMPLE_RATE, sample_rate);
328 }
329 if let Some(sampled) = dsc.sampled {
330 attributes.insert(DSC_SAMPLED, sampled);
331 }
332}
333
334pub fn normalize_attribute_names(attributes: &mut Annotated<Attributes>) {
343 normalize_attribute_names_inner(attributes, relay_conventions::attribute_info)
344}
345
346fn normalize_attribute_names_inner(
347 attributes: &mut Annotated<Attributes>,
348 attribute_info: fn(&str) -> Option<&'static AttributeInfo>,
349) {
350 let Some(attributes) = attributes.value_mut() else {
351 return;
352 };
353
354 let attribute_names: Vec<_> = attributes.0.keys().cloned().collect();
355
356 for name in attribute_names {
357 let Some(attribute_info) = attribute_info(&name) else {
358 continue;
359 };
360
361 match attribute_info.write_behavior {
362 WriteBehavior::CurrentName => continue,
363 WriteBehavior::NewName(new_name) => {
364 let Some(old_attribute) = attributes.0.get_mut(&name) else {
365 continue;
366 };
367
368 let mut meta = Meta::default();
369 meta.add_remark(Remark::new(RemarkType::Removed, "attribute.deprecated"));
371 let new_attribute = std::mem::replace(old_attribute, Annotated(None, meta));
372
373 if !attributes.contains_key(new_name) {
374 attributes.0.insert(new_name.to_owned(), new_attribute);
375 }
376 }
377 WriteBehavior::BothNames(new_name) => {
378 if !attributes.contains_key(new_name)
379 && let Some(current_attribute) = attributes.0.get(&name).cloned()
380 {
381 attributes.0.insert(new_name.to_owned(), current_attribute);
382 }
383 }
384 }
385 }
386}
387
388pub fn normalize_attribute_values(
397 attributes: &mut Annotated<Attributes>,
398 http_span_allowed_hosts: &[String],
399) {
400 normalize_db_attributes(attributes);
401 normalize_http_attributes(attributes, http_span_allowed_hosts);
402}
403
404fn normalize_db_attributes(annotated_attributes: &mut Annotated<Attributes>) {
413 let Some(attributes) = annotated_attributes.value() else {
414 return;
415 };
416
417 if attributes.get_value(NORMALIZED_DB_QUERY).is_some() {
419 return;
420 }
421
422 let (op, sub_op) = attributes
423 .get_value(OP)
424 .and_then(|v| v.as_str())
425 .map(|op| op.split_once('.').unwrap_or((op, "")))
426 .unwrap_or_default();
427
428 let raw_query = attributes
429 .get_value(DB_QUERY_TEXT)
430 .or_else(|| {
431 if op == "db" {
432 attributes.get_value(DESCRIPTION)
433 } else {
434 None
435 }
436 })
437 .and_then(|v| v.as_str());
438
439 let db_system = attributes
440 .get_value(DB_SYSTEM_NAME)
441 .and_then(|v| v.as_str());
442
443 let db_operation = attributes
444 .get_value(DB_OPERATION_NAME)
445 .and_then(|v| v.as_str());
446
447 let collection_name = attributes
448 .get_value(DB_COLLECTION_NAME)
449 .and_then(|v| v.as_str());
450
451 let span_origin = attributes.get_value(ORIGIN).and_then(|v| v.as_str());
452
453 let (normalized_db_query, parsed_sql) = if let Some(raw_query) = raw_query {
454 scrub_db_query(
455 raw_query,
456 sub_op,
457 db_system,
458 db_operation,
459 collection_name,
460 span_origin,
461 )
462 } else {
463 (None, None)
464 };
465
466 let db_operation = if db_operation.is_none() {
467 if sub_op == "redis" || db_system == Some("redis") {
468 if let Some(query) = normalized_db_query.as_ref() {
470 let command = query.replace(" *", "");
471 if command.is_empty() {
472 None
473 } else {
474 Some(command)
475 }
476 } else {
477 None
478 }
479 } else if let Some(raw_query) = raw_query {
480 sql_action_from_query(raw_query).map(|a| a.to_uppercase())
482 } else {
483 None
484 }
485 } else {
486 db_operation.map(|db_operation| db_operation.to_uppercase())
487 };
488
489 let db_collection_name: Option<String> = if let Some(name) = collection_name {
490 if db_system == Some("mongodb") {
491 match TABLE_NAME_REGEX.replace_all(name, "{%s}") {
492 Cow::Owned(s) => Some(s),
493 Cow::Borrowed(_) => Some(name.to_owned()),
494 }
495 } else {
496 Some(name.to_owned())
497 }
498 } else if span_origin == Some("auto.db.supabase") {
499 normalized_db_query
500 .as_ref()
501 .and_then(|query| query.strip_prefix("from("))
502 .and_then(|s| s.strip_suffix(")"))
503 .map(String::from)
504 } else if let Some(raw_query) = raw_query {
505 sql_tables_from_query(raw_query, &parsed_sql)
506 } else {
507 None
508 };
509
510 if let Some(attributes) = annotated_attributes.value_mut() {
511 if let Some(normalized_db_query) = normalized_db_query {
512 let mut normalized_db_query_hash = format!("{:x}", md5::compute(&normalized_db_query));
513 normalized_db_query_hash.truncate(16);
514
515 attributes.insert(NORMALIZED_DB_QUERY, normalized_db_query);
516 attributes.insert(NORMALIZED_DB_QUERY_HASH, normalized_db_query_hash);
517 }
518 if let Some(db_operation_name) = db_operation {
519 attributes.insert(DB_OPERATION_NAME, db_operation_name)
520 }
521 if let Some(db_collection_name) = db_collection_name {
522 attributes.insert(DB_COLLECTION_NAME, db_collection_name);
523 }
524 }
525}
526
527fn normalize_http_attributes(
532 annotated_attributes: &mut Annotated<Attributes>,
533 allowed_hosts: &[String],
534) {
535 let Some(attributes) = annotated_attributes.value() else {
536 return;
537 };
538
539 if attributes
541 .get_value(SENTRY_CATEGORY)
542 .is_none_or(|category| category.as_str().unwrap_or_default() != "http")
543 {
544 return;
545 }
546
547 let op = attributes.get_value(OP).and_then(|v| v.as_str());
548
549 let (description_method, description_url) = match attributes
550 .get_value(DESCRIPTION)
551 .and_then(|v| v.as_str())
552 .and_then(|description| description.split_once(' '))
553 {
554 Some((method, url)) => (Some(method), Some(url)),
555 _ => (None, None),
556 };
557
558 let method = attributes
559 .get_value(HTTP_REQUEST_METHOD)
560 .or_else(|| attributes.get_value(LEGACY_HTTP_REQUEST_METHOD))
561 .and_then(|v| v.as_str())
562 .or(description_method);
563
564 let server_address = attributes
565 .get_value(SERVER_ADDRESS)
566 .and_then(|v| v.as_str());
567
568 let url: Option<&str> = attributes
569 .get_value(URL_FULL)
570 .and_then(|v| v.as_str())
571 .or(description_url);
572 let url_scheme = attributes.get_value(URL_SCHEME).and_then(|v| v.as_str());
573
574 let (normalized_server_address, raw_url) = if op == Some("http.client") {
577 let domain_from_scrubbed_http = method
578 .zip(url)
579 .and_then(|(method, url)| scrub_http(method, url, allowed_hosts))
580 .and_then(|scrubbed_http| domain_from_scrubbed_http(&scrubbed_http));
581
582 if let Some(domain) = domain_from_scrubbed_http {
583 (Some(domain), url.map(String::from))
584 } else {
585 domain_from_server_address(server_address, url_scheme)
586 }
587 } else {
588 (None, None)
589 };
590
591 let method = method.map(|m| m.to_uppercase());
592
593 if let Some(attributes) = annotated_attributes.value_mut() {
594 if let Some(method) = method {
595 attributes.insert(HTTP_REQUEST_METHOD, method);
596 }
597
598 if let Some(normalized_server_address) = normalized_server_address {
599 attributes.insert(SERVER_ADDRESS, normalized_server_address);
600 }
601
602 if let Some(raw_url) = raw_url {
603 attributes.insert_if_missing(URL_FULL, || raw_url);
604 }
605 }
606}
607
608pub fn write_legacy_attributes(attributes: &mut Annotated<Attributes>) {
616 let Some(attributes) = attributes.value_mut() else {
617 return;
618 };
619
620 let current_to_legacy_attributes = [
622 (DB_QUERY_TEXT, DESCRIPTION),
624 (NORMALIZED_DB_QUERY, SENTRY_NORMALIZED_DESCRIPTION),
625 (DB_OPERATION_NAME, SENTRY_ACTION),
626 (DB_SYSTEM_NAME, DB_SYSTEM),
627 (SERVER_ADDRESS, SENTRY_DOMAIN),
629 (HTTP_REQUEST_METHOD, SENTRY_ACTION),
630 (HTTP_RESPONSE_STATUS_CODE, SENTRY_STATUS_CODE),
631 ];
632
633 for (current_attribute, legacy_attribute) in current_to_legacy_attributes {
634 if attributes.contains_key(current_attribute) {
635 let Some(attr) = attributes.get_attribute(current_attribute) else {
636 continue;
637 };
638 attributes.insert(legacy_attribute, attr.value.clone());
639 }
640 }
641
642 if !attributes.contains_key(SENTRY_DOMAIN)
643 && let Some(db_domain) = attributes
644 .get_value(DB_COLLECTION_NAME)
645 .and_then(|value| value.as_str())
646 .map(|collection_name| collection_name.to_owned())
647 {
648 attributes.insert(
650 SENTRY_DOMAIN,
651 match (db_domain.starts_with(','), db_domain.ends_with(',')) {
652 (true, true) => db_domain,
653 (true, false) => format!("{db_domain},"),
654 (false, true) => format!(",{db_domain}"),
655 (false, false) => format!(",{db_domain},"),
656 },
657 );
658 }
659
660 if let Some(&Value::String(method)) = attributes.get_value(HTTP_REQUEST_METHOD).as_ref()
661 && let Some(&Value::String(url)) = attributes.get_value(URL_FULL).as_ref()
662 {
663 attributes.insert(DESCRIPTION, format!("{method} {url}"))
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use relay_protocol::SerializableAnnotated;
670
671 use super::*;
672
673 #[test]
674 fn test_normalize_received_none() {
675 let mut attributes = Default::default();
676
677 normalize_received(
678 &mut attributes,
679 DateTime::from_timestamp_nanos(1_234_201_337),
680 );
681
682 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
683 {
684 "sentry.observed_timestamp_nanos": {
685 "type": "string",
686 "value": "1234201337"
687 }
688 }
689 "#);
690 }
691
692 #[test]
693 fn test_normalize_received_existing() {
694 let mut attributes = Annotated::from_json(
695 r#"{
696 "sentry.observed_timestamp_nanos": {
697 "type": "string",
698 "value": "111222333"
699 }
700 }"#,
701 )
702 .unwrap();
703
704 normalize_received(
705 &mut attributes,
706 DateTime::from_timestamp_nanos(1_234_201_337),
707 );
708
709 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
710 {
711 "sentry.observed_timestamp_nanos": {
712 "type": "string",
713 "value": "111222333"
714 }
715 }
716 "###);
717 }
718
719 #[test]
720 fn test_process_attribute_types() {
721 let json = r#"{
722 "valid_bool": {
723 "type": "boolean",
724 "value": true
725 },
726 "valid_int_i64": {
727 "type": "integer",
728 "value": -42
729 },
730 "valid_int_u64": {
731 "type": "integer",
732 "value": 42
733 },
734 "valid_int_from_string": {
735 "type": "integer",
736 "value": "42"
737 },
738 "valid_double": {
739 "type": "double",
740 "value": 42.5
741 },
742 "double_with_i64": {
743 "type": "double",
744 "value": -42
745 },
746 "valid_double_with_u64": {
747 "type": "double",
748 "value": 42
749 },
750 "valid_string": {
751 "type": "string",
752 "value": "test"
753 },
754 "valid_string_with_other": {
755 "type": "string",
756 "value": "test",
757 "some_other_field": "some_other_value"
758 },
759 "unknown_type": {
760 "type": "custom",
761 "value": "test"
762 },
763 "invalid_int_from_invalid_string": {
764 "type": "integer",
765 "value": "abc"
766 },
767 "missing_type": {
768 "value": "value with missing type"
769 },
770 "missing_value": {
771 "type": "string"
772 },
773 "supported_array_string": {
774 "type": "array",
775 "value": ["foo", "bar"]
776 },
777 "supported_array_double": {
778 "type": "array",
779 "value": [3, 3.0, 3]
780 },
781 "supported_array_null": {
782 "type": "array",
783 "value": [null, null]
784 },
785 "unsupported_array_mixed": {
786 "type": "array",
787 "value": ["foo", 1.0]
788 },
789 "unsupported_array_object": {
790 "type": "array",
791 "value": [{}]
792 },
793 "unsupported_array_in_array": {
794 "type": "array",
795 "value": [[]]
796 }
797 }"#;
798
799 let mut attributes = Annotated::<Attributes>::from_json(json).unwrap();
800 normalize_attribute_types(&mut attributes);
801
802 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
803 {
804 "double_with_i64": {
805 "type": "double",
806 "value": -42
807 },
808 "invalid_int_from_invalid_string": null,
809 "missing_type": null,
810 "missing_value": null,
811 "supported_array_double": {
812 "type": "array",
813 "value": [
814 3,
815 3.0,
816 3
817 ]
818 },
819 "supported_array_null": {
820 "type": "array",
821 "value": [
822 null,
823 null
824 ]
825 },
826 "supported_array_string": {
827 "type": "array",
828 "value": [
829 "foo",
830 "bar"
831 ]
832 },
833 "unknown_type": null,
834 "unsupported_array_in_array": null,
835 "unsupported_array_mixed": null,
836 "unsupported_array_object": null,
837 "valid_bool": {
838 "type": "boolean",
839 "value": true
840 },
841 "valid_double": {
842 "type": "double",
843 "value": 42.5
844 },
845 "valid_double_with_u64": {
846 "type": "double",
847 "value": 42
848 },
849 "valid_int_from_string": null,
850 "valid_int_i64": {
851 "type": "integer",
852 "value": -42
853 },
854 "valid_int_u64": {
855 "type": "integer",
856 "value": 42
857 },
858 "valid_string": {
859 "type": "string",
860 "value": "test"
861 },
862 "valid_string_with_other": {
863 "type": "string",
864 "value": "test",
865 "some_other_field": "some_other_value"
866 },
867 "_meta": {
868 "invalid_int_from_invalid_string": {
869 "": {
870 "err": [
871 "invalid_data"
872 ],
873 "val": {
874 "type": "integer",
875 "value": "abc"
876 }
877 }
878 },
879 "missing_type": {
880 "": {
881 "err": [
882 "missing_attribute"
883 ],
884 "val": {
885 "type": null,
886 "value": "value with missing type"
887 }
888 }
889 },
890 "missing_value": {
891 "": {
892 "err": [
893 "missing_attribute"
894 ],
895 "val": {
896 "type": "string",
897 "value": null
898 }
899 }
900 },
901 "unknown_type": {
902 "": {
903 "err": [
904 "invalid_data"
905 ],
906 "val": {
907 "type": "custom",
908 "value": "test"
909 }
910 }
911 },
912 "unsupported_array_in_array": {
913 "": {
914 "err": [
915 "invalid_data"
916 ]
917 }
918 },
919 "unsupported_array_mixed": {
920 "": {
921 "err": [
922 "invalid_data"
923 ]
924 }
925 },
926 "unsupported_array_object": {
927 "": {
928 "err": [
929 "invalid_data"
930 ]
931 }
932 },
933 "valid_int_from_string": {
934 "": {
935 "err": [
936 "invalid_data"
937 ],
938 "val": {
939 "type": "integer",
940 "value": "42"
941 }
942 }
943 }
944 }
945 }
946 "#);
947 }
948
949 #[test]
950 fn test_normalize_user_agent_none() {
951 let mut attributes = Default::default();
952 normalize_user_agent(
953 &mut attributes,
954 Some(
955 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
956 ),
957 ClientHints::default(),
958 );
959
960 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
961 {
962 "sentry.browser.name": {
963 "type": "string",
964 "value": "Chrome"
965 },
966 "sentry.browser.version": {
967 "type": "string",
968 "value": "131.0.0"
969 }
970 }
971 "#);
972 }
973
974 #[test]
975 fn test_normalize_user_agent_existing() {
976 let mut attributes = Annotated::from_json(
977 r#"{
978 "sentry.browser.name": {
979 "type": "string",
980 "value": "Very Special"
981 },
982 "sentry.browser.version": {
983 "type": "string",
984 "value": "13.3.7"
985 }
986 }"#,
987 )
988 .unwrap();
989
990 normalize_user_agent(
991 &mut attributes,
992 Some(
993 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
994 ),
995 ClientHints::default(),
996 );
997
998 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
999 {
1000 "sentry.browser.name": {
1001 "type": "string",
1002 "value": "Very Special"
1003 },
1004 "sentry.browser.version": {
1005 "type": "string",
1006 "value": "13.3.7"
1007 }
1008 }
1009 "#,
1010 );
1011 }
1012
1013 #[test]
1014 fn test_normalize_user_geo_none() {
1015 let mut attributes = Default::default();
1016
1017 normalize_user_geo(&mut attributes, || {
1018 Some(Geo {
1019 country_code: "XY".to_owned().into(),
1020 city: "Foo Hausen".to_owned().into(),
1021 subdivision: Annotated::empty(),
1022 region: "Illu".to_owned().into(),
1023 other: Default::default(),
1024 })
1025 });
1026
1027 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1028 {
1029 "user.geo.city": {
1030 "type": "string",
1031 "value": "Foo Hausen"
1032 },
1033 "user.geo.country_code": {
1034 "type": "string",
1035 "value": "XY"
1036 },
1037 "user.geo.region": {
1038 "type": "string",
1039 "value": "Illu"
1040 }
1041 }
1042 "#);
1043 }
1044
1045 #[test]
1046 fn test_normalize_user_geo_existing() {
1047 let mut attributes = Annotated::from_json(
1048 r#"{
1049 "user.geo.city": {
1050 "type": "string",
1051 "value": "Foo Hausen"
1052 }
1053 }"#,
1054 )
1055 .unwrap();
1056
1057 normalize_user_geo(&mut attributes, || unreachable!());
1058
1059 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1060 {
1061 "user.geo.city": {
1062 "type": "string",
1063 "value": "Foo Hausen"
1064 }
1065 }
1066 "#,
1067 );
1068 }
1069
1070 #[test]
1071 fn test_normalize_attributes() {
1072 fn mock_attribute_info(name: &str) -> Option<&'static AttributeInfo> {
1073 use relay_conventions::Pii;
1074
1075 match name {
1076 "replace.empty" => Some(&AttributeInfo {
1077 write_behavior: WriteBehavior::NewName("replaced"),
1078 pii: Pii::Maybe,
1079 aliases: &["replaced"],
1080 }),
1081 "replace.existing" => Some(&AttributeInfo {
1082 write_behavior: WriteBehavior::NewName("not.replaced"),
1083 pii: Pii::Maybe,
1084 aliases: &["not.replaced"],
1085 }),
1086 "backfill.empty" => Some(&AttributeInfo {
1087 write_behavior: WriteBehavior::BothNames("backfilled"),
1088 pii: Pii::Maybe,
1089 aliases: &["backfilled"],
1090 }),
1091 "backfill.existing" => Some(&AttributeInfo {
1092 write_behavior: WriteBehavior::BothNames("not.backfilled"),
1093 pii: Pii::Maybe,
1094 aliases: &["not.backfilled"],
1095 }),
1096 _ => None,
1097 }
1098 }
1099
1100 let mut attributes = Annotated::new(Attributes::from([
1101 (
1102 "replace.empty".to_owned(),
1103 Annotated::new("Should be moved".to_owned().into()),
1104 ),
1105 (
1106 "replace.existing".to_owned(),
1107 Annotated::new("Should be removed".to_owned().into()),
1108 ),
1109 (
1110 "not.replaced".to_owned(),
1111 Annotated::new("Should be left alone".to_owned().into()),
1112 ),
1113 (
1114 "backfill.empty".to_owned(),
1115 Annotated::new("Should be copied".to_owned().into()),
1116 ),
1117 (
1118 "backfill.existing".to_owned(),
1119 Annotated::new("Should be left alone".to_owned().into()),
1120 ),
1121 (
1122 "not.backfilled".to_owned(),
1123 Annotated::new("Should be left alone".to_owned().into()),
1124 ),
1125 ]));
1126
1127 normalize_attribute_names_inner(&mut attributes, mock_attribute_info);
1128
1129 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
1130 {
1131 "backfill.empty": {
1132 "type": "string",
1133 "value": "Should be copied"
1134 },
1135 "backfill.existing": {
1136 "type": "string",
1137 "value": "Should be left alone"
1138 },
1139 "backfilled": {
1140 "type": "string",
1141 "value": "Should be copied"
1142 },
1143 "not.backfilled": {
1144 "type": "string",
1145 "value": "Should be left alone"
1146 },
1147 "not.replaced": {
1148 "type": "string",
1149 "value": "Should be left alone"
1150 },
1151 "replace.empty": null,
1152 "replace.existing": null,
1153 "replaced": {
1154 "type": "string",
1155 "value": "Should be moved"
1156 },
1157 "_meta": {
1158 "replace.empty": {
1159 "": {
1160 "rem": [
1161 [
1162 "attribute.deprecated",
1163 "x"
1164 ]
1165 ]
1166 }
1167 },
1168 "replace.existing": {
1169 "": {
1170 "rem": [
1171 [
1172 "attribute.deprecated",
1173 "x"
1174 ]
1175 ]
1176 }
1177 }
1178 }
1179 }
1180 "###);
1181 }
1182
1183 #[test]
1184 fn test_normalize_span_infers_op() {
1185 let mut attributes = Annotated::<Attributes>::from_json(
1186 r#"{
1187 "db.system.name": {
1188 "type": "string",
1189 "value": "mysql"
1190 },
1191 "db.operation.name": {
1192 "type": "string",
1193 "value": "query"
1194 }
1195 }
1196 "#,
1197 )
1198 .unwrap();
1199
1200 normalize_sentry_op(&mut attributes);
1201
1202 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1203 {
1204 "db.operation.name": {
1205 "type": "string",
1206 "value": "query"
1207 },
1208 "db.system.name": {
1209 "type": "string",
1210 "value": "mysql"
1211 },
1212 "sentry.op": {
1213 "type": "string",
1214 "value": "db"
1215 }
1216 }
1217 "#);
1218 }
1219
1220 #[test]
1221 fn test_normalize_attribute_values_mysql_db_query_attributes() {
1222 let mut attributes = Annotated::<Attributes>::from_json(
1223 r#"
1224 {
1225 "sentry.op": {
1226 "type": "string",
1227 "value": "db.query"
1228 },
1229 "sentry.origin": {
1230 "type": "string",
1231 "value": "auto.otlp.spans"
1232 },
1233 "db.system.name": {
1234 "type": "string",
1235 "value": "mysql"
1236 },
1237 "db.query.text": {
1238 "type": "string",
1239 "value": "SELECT \"not an identifier\""
1240 }
1241 }
1242 "#,
1243 )
1244 .unwrap();
1245
1246 normalize_db_attributes(&mut attributes);
1247
1248 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1249 {
1250 "db.operation.name": {
1251 "type": "string",
1252 "value": "SELECT"
1253 },
1254 "db.query.text": {
1255 "type": "string",
1256 "value": "SELECT \"not an identifier\""
1257 },
1258 "db.system.name": {
1259 "type": "string",
1260 "value": "mysql"
1261 },
1262 "sentry.normalized_db_query": {
1263 "type": "string",
1264 "value": "SELECT %s"
1265 },
1266 "sentry.normalized_db_query.hash": {
1267 "type": "string",
1268 "value": "3a377dcc490b1690"
1269 },
1270 "sentry.op": {
1271 "type": "string",
1272 "value": "db.query"
1273 },
1274 "sentry.origin": {
1275 "type": "string",
1276 "value": "auto.otlp.spans"
1277 }
1278 }
1279 "#);
1280 }
1281
1282 #[test]
1283 fn test_normalize_mongodb_db_query_attributes() {
1284 let mut attributes = Annotated::<Attributes>::from_json(
1285 r#"
1286 {
1287 "sentry.op": {
1288 "type": "string",
1289 "value": "db"
1290 },
1291 "db.system.name": {
1292 "type": "string",
1293 "value": "mongodb"
1294 },
1295 "db.query.text": {
1296 "type": "string",
1297 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1298 },
1299 "db.operation.name": {
1300 "type": "string",
1301 "value": "find"
1302 },
1303 "db.collection.name": {
1304 "type": "string",
1305 "value": "documents"
1306 }
1307 }
1308 "#,
1309 )
1310 .unwrap();
1311
1312 normalize_db_attributes(&mut attributes);
1313
1314 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1315 {
1316 "db.collection.name": {
1317 "type": "string",
1318 "value": "documents"
1319 },
1320 "db.operation.name": {
1321 "type": "string",
1322 "value": "FIND"
1323 },
1324 "db.query.text": {
1325 "type": "string",
1326 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1327 },
1328 "db.system.name": {
1329 "type": "string",
1330 "value": "mongodb"
1331 },
1332 "sentry.normalized_db_query": {
1333 "type": "string",
1334 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1335 },
1336 "sentry.normalized_db_query.hash": {
1337 "type": "string",
1338 "value": "aedc5c7e8cec726b"
1339 },
1340 "sentry.op": {
1341 "type": "string",
1342 "value": "db"
1343 }
1344 }
1345 "#);
1346 }
1347
1348 #[test]
1349 fn test_normalize_db_attributes_does_not_update_attributes_if_already_normalized() {
1350 let mut attributes = Annotated::<Attributes>::from_json(
1351 r#"
1352 {
1353 "db.collection.name": {
1354 "type": "string",
1355 "value": "documents"
1356 },
1357 "db.operation.name": {
1358 "type": "string",
1359 "value": "FIND"
1360 },
1361 "db.query.text": {
1362 "type": "string",
1363 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1364 },
1365 "db.system.name": {
1366 "type": "string",
1367 "value": "mongodb"
1368 },
1369 "sentry.normalized_db_query": {
1370 "type": "string",
1371 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1372 },
1373 "sentry.op": {
1374 "type": "string",
1375 "value": "db"
1376 }
1377 }
1378 "#,
1379 )
1380 .unwrap();
1381
1382 normalize_db_attributes(&mut attributes);
1383
1384 insta::assert_json_snapshot!(
1385 SerializableAnnotated(&attributes), @r#"
1386 {
1387 "db.collection.name": {
1388 "type": "string",
1389 "value": "documents"
1390 },
1391 "db.operation.name": {
1392 "type": "string",
1393 "value": "FIND"
1394 },
1395 "db.query.text": {
1396 "type": "string",
1397 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1398 },
1399 "db.system.name": {
1400 "type": "string",
1401 "value": "mongodb"
1402 },
1403 "sentry.normalized_db_query": {
1404 "type": "string",
1405 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1406 },
1407 "sentry.op": {
1408 "type": "string",
1409 "value": "db"
1410 }
1411 }
1412 "#
1413 );
1414 }
1415
1416 #[test]
1417 fn test_normalize_db_attributes_does_not_change_non_db_spans() {
1418 let mut attributes = Annotated::<Attributes>::from_json(
1419 r#"
1420 {
1421 "sentry.op": {
1422 "type": "string",
1423 "value": "http.client"
1424 },
1425 "sentry.origin": {
1426 "type": "string",
1427 "value": "auto.otlp.spans"
1428 },
1429 "http.request.method": {
1430 "type": "string",
1431 "value": "GET"
1432 }
1433 }
1434 "#,
1435 )
1436 .unwrap();
1437
1438 normalize_db_attributes(&mut attributes);
1439
1440 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1441 {
1442 "http.request.method": {
1443 "type": "string",
1444 "value": "GET"
1445 },
1446 "sentry.op": {
1447 "type": "string",
1448 "value": "http.client"
1449 },
1450 "sentry.origin": {
1451 "type": "string",
1452 "value": "auto.otlp.spans"
1453 }
1454 }
1455 "#);
1456 }
1457
1458 #[test]
1459 fn test_normalize_http_attributes() {
1460 let mut attributes = Annotated::<Attributes>::from_json(
1461 r#"
1462 {
1463 "sentry.op": {
1464 "type": "string",
1465 "value": "http.client"
1466 },
1467 "sentry.category": {
1468 "type": "string",
1469 "value": "http"
1470 },
1471 "http.request.method": {
1472 "type": "string",
1473 "value": "GET"
1474 },
1475 "url.full": {
1476 "type": "string",
1477 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1478 }
1479 }
1480 "#,
1481 )
1482 .unwrap();
1483
1484 normalize_http_attributes(&mut attributes, &[]);
1485
1486 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1487 {
1488 "http.request.method": {
1489 "type": "string",
1490 "value": "GET"
1491 },
1492 "sentry.category": {
1493 "type": "string",
1494 "value": "http"
1495 },
1496 "sentry.op": {
1497 "type": "string",
1498 "value": "http.client"
1499 },
1500 "server.address": {
1501 "type": "string",
1502 "value": "*.xn--85x722f.xn--55qx5d.cn"
1503 },
1504 "url.full": {
1505 "type": "string",
1506 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1507 }
1508 }
1509 "#);
1510 }
1511
1512 #[test]
1513 fn test_normalize_http_attributes_server_address() {
1514 let mut attributes = Annotated::<Attributes>::from_json(
1515 r#"
1516 {
1517 "sentry.category": {
1518 "type": "string",
1519 "value": "http"
1520 },
1521 "sentry.op": {
1522 "type": "string",
1523 "value": "http.client"
1524 },
1525 "url.scheme": {
1526 "type": "string",
1527 "value": "https"
1528 },
1529 "server.address": {
1530 "type": "string",
1531 "value": "subdomain.example.com:5688"
1532 },
1533 "http.request.method": {
1534 "type": "string",
1535 "value": "GET"
1536 }
1537 }
1538 "#,
1539 )
1540 .unwrap();
1541
1542 normalize_http_attributes(&mut attributes, &[]);
1543
1544 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1545 {
1546 "http.request.method": {
1547 "type": "string",
1548 "value": "GET"
1549 },
1550 "sentry.category": {
1551 "type": "string",
1552 "value": "http"
1553 },
1554 "sentry.op": {
1555 "type": "string",
1556 "value": "http.client"
1557 },
1558 "server.address": {
1559 "type": "string",
1560 "value": "*.example.com:5688"
1561 },
1562 "url.full": {
1563 "type": "string",
1564 "value": "https://subdomain.example.com:5688"
1565 },
1566 "url.scheme": {
1567 "type": "string",
1568 "value": "https"
1569 }
1570 }
1571 "#);
1572 }
1573
1574 #[test]
1575 fn test_normalize_http_attributes_allowed_hosts() {
1576 let mut attributes = Annotated::<Attributes>::from_json(
1577 r#"
1578 {
1579 "sentry.category": {
1580 "type": "string",
1581 "value": "http"
1582 },
1583 "sentry.op": {
1584 "type": "string",
1585 "value": "http.client"
1586 },
1587 "http.request.method": {
1588 "type": "string",
1589 "value": "GET"
1590 },
1591 "url.full": {
1592 "type": "string",
1593 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1594 }
1595 }
1596 "#,
1597 )
1598 .unwrap();
1599
1600 normalize_http_attributes(
1601 &mut attributes,
1602 &["application.www.xn--85x722f.xn--55qx5d.cn".to_owned()],
1603 );
1604
1605 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1606 {
1607 "http.request.method": {
1608 "type": "string",
1609 "value": "GET"
1610 },
1611 "sentry.category": {
1612 "type": "string",
1613 "value": "http"
1614 },
1615 "sentry.op": {
1616 "type": "string",
1617 "value": "http.client"
1618 },
1619 "server.address": {
1620 "type": "string",
1621 "value": "application.www.xn--85x722f.xn--55qx5d.cn"
1622 },
1623 "url.full": {
1624 "type": "string",
1625 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1626 }
1627 }
1628 "#);
1629 }
1630
1631 #[test]
1632 fn test_normalize_db_attributes_from_legacy_attributes() {
1633 let mut attributes = Annotated::<Attributes>::from_json(
1634 r#"
1635 {
1636 "sentry.op": {
1637 "type": "string",
1638 "value": "db"
1639 },
1640 "db.system.name": {
1641 "type": "string",
1642 "value": "mongodb"
1643 },
1644 "sentry.description": {
1645 "type": "string",
1646 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1647 },
1648 "db.operation.name": {
1649 "type": "string",
1650 "value": "find"
1651 },
1652 "db.collection.name": {
1653 "type": "string",
1654 "value": "documents"
1655 }
1656 }
1657 "#,
1658 )
1659 .unwrap();
1660
1661 normalize_db_attributes(&mut attributes);
1662
1663 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1664 {
1665 "db.collection.name": {
1666 "type": "string",
1667 "value": "documents"
1668 },
1669 "db.operation.name": {
1670 "type": "string",
1671 "value": "FIND"
1672 },
1673 "db.system.name": {
1674 "type": "string",
1675 "value": "mongodb"
1676 },
1677 "sentry.description": {
1678 "type": "string",
1679 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1680 },
1681 "sentry.normalized_db_query": {
1682 "type": "string",
1683 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1684 },
1685 "sentry.normalized_db_query.hash": {
1686 "type": "string",
1687 "value": "aedc5c7e8cec726b"
1688 },
1689 "sentry.op": {
1690 "type": "string",
1691 "value": "db"
1692 }
1693 }
1694 "#);
1695 }
1696
1697 #[test]
1698 fn test_normalize_http_attributes_from_legacy_attributes() {
1699 let mut attributes = Annotated::<Attributes>::from_json(
1700 r#"
1701 {
1702 "sentry.category": {
1703 "type": "string",
1704 "value": "http"
1705 },
1706 "sentry.op": {
1707 "type": "string",
1708 "value": "http.client"
1709 },
1710 "http.request_method": {
1711 "type": "string",
1712 "value": "GET"
1713 },
1714 "sentry.description": {
1715 "type": "string",
1716 "value": "GET https://application.www.xn--85x722f.xn--55qx5d.cn"
1717 }
1718 }
1719 "#,
1720 )
1721 .unwrap();
1722
1723 normalize_http_attributes(&mut attributes, &[]);
1724
1725 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1726 {
1727 "http.request.method": {
1728 "type": "string",
1729 "value": "GET"
1730 },
1731 "http.request_method": {
1732 "type": "string",
1733 "value": "GET"
1734 },
1735 "sentry.category": {
1736 "type": "string",
1737 "value": "http"
1738 },
1739 "sentry.description": {
1740 "type": "string",
1741 "value": "GET https://application.www.xn--85x722f.xn--55qx5d.cn"
1742 },
1743 "sentry.op": {
1744 "type": "string",
1745 "value": "http.client"
1746 },
1747 "server.address": {
1748 "type": "string",
1749 "value": "*.xn--85x722f.xn--55qx5d.cn"
1750 },
1751 "url.full": {
1752 "type": "string",
1753 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1754 }
1755 }
1756 "#);
1757 }
1758
1759 #[test]
1760 fn test_write_legacy_attributes() {
1761 let mut attributes = Annotated::<Attributes>::from_json(
1762 r#"
1763 {
1764 "db.collection.name": {
1765 "type": "string",
1766 "value": "documents"
1767 },
1768 "db.operation.name": {
1769 "type": "string",
1770 "value": "FIND"
1771 },
1772 "db.query.text": {
1773 "type": "string",
1774 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1775 },
1776 "db.system.name": {
1777 "type": "string",
1778 "value": "mongodb"
1779 },
1780 "sentry.normalized_db_query": {
1781 "type": "string",
1782 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1783 },
1784 "sentry.normalized_db_query.hash": {
1785 "type": "string",
1786 "value": "aedc5c7e8cec726b"
1787 },
1788 "sentry.op": {
1789 "type": "string",
1790 "value": "db"
1791 }
1792 }
1793 "#,
1794 )
1795 .unwrap();
1796
1797 write_legacy_attributes(&mut attributes);
1798
1799 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1800 {
1801 "db.collection.name": {
1802 "type": "string",
1803 "value": "documents"
1804 },
1805 "db.operation.name": {
1806 "type": "string",
1807 "value": "FIND"
1808 },
1809 "db.query.text": {
1810 "type": "string",
1811 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1812 },
1813 "db.system": {
1814 "type": "string",
1815 "value": "mongodb"
1816 },
1817 "db.system.name": {
1818 "type": "string",
1819 "value": "mongodb"
1820 },
1821 "sentry.action": {
1822 "type": "string",
1823 "value": "FIND"
1824 },
1825 "sentry.description": {
1826 "type": "string",
1827 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1828 },
1829 "sentry.domain": {
1830 "type": "string",
1831 "value": ",documents,"
1832 },
1833 "sentry.normalized_db_query": {
1834 "type": "string",
1835 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1836 },
1837 "sentry.normalized_db_query.hash": {
1838 "type": "string",
1839 "value": "aedc5c7e8cec726b"
1840 },
1841 "sentry.normalized_description": {
1842 "type": "string",
1843 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1844 },
1845 "sentry.op": {
1846 "type": "string",
1847 "value": "db"
1848 }
1849 }
1850 "#);
1851 }
1852
1853 #[test]
1854 fn test_normalize_span_category_explicit() {
1855 let mut attributes = Annotated::<Attributes>::from_json(
1857 r#"{
1858 "sentry.category": {
1859 "type": "string",
1860 "value": "custom"
1861 },
1862 "sentry.op": {
1863 "type": "string",
1864 "value": "db.query"
1865 }
1866 }"#,
1867 )
1868 .unwrap();
1869
1870 normalize_span_category(&mut attributes);
1871
1872 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1873 {
1874 "sentry.category": {
1875 "type": "string",
1876 "value": "custom"
1877 },
1878 "sentry.op": {
1879 "type": "string",
1880 "value": "db.query"
1881 }
1882 }
1883 "#);
1884 }
1885
1886 #[test]
1887 fn test_normalize_span_category_from_op_db() {
1888 let mut attributes = Annotated::<Attributes>::from_json(
1889 r#"{
1890 "sentry.op": {
1891 "type": "string",
1892 "value": "db.query"
1893 }
1894 }"#,
1895 )
1896 .unwrap();
1897
1898 normalize_span_category(&mut attributes);
1899
1900 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1901 {
1902 "sentry.category": {
1903 "type": "string",
1904 "value": "db"
1905 },
1906 "sentry.op": {
1907 "type": "string",
1908 "value": "db.query"
1909 }
1910 }
1911 "#);
1912 }
1913
1914 #[test]
1915 fn test_normalize_span_category_from_op_http() {
1916 let mut attributes = Annotated::<Attributes>::from_json(
1917 r#"{
1918 "sentry.op": {
1919 "type": "string",
1920 "value": "http.client"
1921 }
1922 }"#,
1923 )
1924 .unwrap();
1925
1926 normalize_span_category(&mut attributes);
1927
1928 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1929 {
1930 "sentry.category": {
1931 "type": "string",
1932 "value": "http"
1933 },
1934 "sentry.op": {
1935 "type": "string",
1936 "value": "http.client"
1937 }
1938 }
1939 "#);
1940 }
1941
1942 #[test]
1943 fn test_normalize_span_category_from_op_ui_framework() {
1944 let mut attributes = Annotated::<Attributes>::from_json(
1945 r#"{
1946 "sentry.op": {
1947 "type": "string",
1948 "value": "ui.react.render"
1949 }
1950 }"#,
1951 )
1952 .unwrap();
1953
1954 normalize_span_category(&mut attributes);
1955
1956 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1957 {
1958 "sentry.category": {
1959 "type": "string",
1960 "value": "ui.react"
1961 },
1962 "sentry.op": {
1963 "type": "string",
1964 "value": "ui.react.render"
1965 }
1966 }
1967 "#);
1968 }
1969
1970 #[test]
1971 fn test_normalize_span_category_from_db_system() {
1972 let mut attributes = Annotated::<Attributes>::from_json(
1974 r#"{
1975 "db.system.name": {
1976 "type": "string",
1977 "value": "mongodb"
1978 }
1979 }"#,
1980 )
1981 .unwrap();
1982
1983 normalize_span_category(&mut attributes);
1984
1985 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1986 {
1987 "db.system.name": {
1988 "type": "string",
1989 "value": "mongodb"
1990 },
1991 "sentry.category": {
1992 "type": "string",
1993 "value": "db"
1994 }
1995 }
1996 "#);
1997 }
1998
1999 #[test]
2000 fn test_normalize_span_category_from_http_method() {
2001 let mut attributes = Annotated::<Attributes>::from_json(
2003 r#"{
2004 "http.request.method": {
2005 "type": "string",
2006 "value": "GET"
2007 }
2008 }"#,
2009 )
2010 .unwrap();
2011
2012 normalize_span_category(&mut attributes);
2013
2014 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2015 {
2016 "http.request.method": {
2017 "type": "string",
2018 "value": "GET"
2019 },
2020 "sentry.category": {
2021 "type": "string",
2022 "value": "http"
2023 }
2024 }
2025 "#);
2026 }
2027
2028 #[test]
2029 fn test_normalize_span_category_from_ui_component() {
2030 let mut attributes = Annotated::<Attributes>::from_json(
2032 r#"{
2033 "ui.component_name": {
2034 "type": "string",
2035 "value": "MyComponent"
2036 }
2037 }"#,
2038 )
2039 .unwrap();
2040
2041 normalize_span_category(&mut attributes);
2042
2043 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2044 {
2045 "sentry.category": {
2046 "type": "string",
2047 "value": "ui"
2048 },
2049 "ui.component_name": {
2050 "type": "string",
2051 "value": "MyComponent"
2052 }
2053 }
2054 "#);
2055 }
2056
2057 #[test]
2058 fn test_normalize_span_category_from_resource() {
2059 let mut attributes = Annotated::<Attributes>::from_json(
2061 r#"{
2062 "resource.render_blocking_status": {
2063 "type": "string",
2064 "value": "blocking"
2065 }
2066 }"#,
2067 )
2068 .unwrap();
2069
2070 normalize_span_category(&mut attributes);
2071
2072 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2073 {
2074 "resource.render_blocking_status": {
2075 "type": "string",
2076 "value": "blocking"
2077 },
2078 "sentry.category": {
2079 "type": "string",
2080 "value": "resource"
2081 }
2082 }
2083 "#);
2084 }
2085
2086 #[test]
2087 fn test_normalize_span_category_from_browser_origin() {
2088 let mut attributes = Annotated::<Attributes>::from_json(
2090 r#"{
2091 "sentry.origin": {
2092 "type": "string",
2093 "value": "auto.ui.browser.metrics"
2094 }
2095 }"#,
2096 )
2097 .unwrap();
2098
2099 normalize_span_category(&mut attributes);
2100
2101 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2102 {
2103 "sentry.category": {
2104 "type": "string",
2105 "value": "browser"
2106 },
2107 "sentry.origin": {
2108 "type": "string",
2109 "value": "auto.ui.browser.metrics"
2110 }
2111 }
2112 "#);
2113 }
2114
2115 #[test]
2116 fn test_normalize_span_category_no_match() {
2117 let mut attributes = Annotated::<Attributes>::from_json(
2119 r#"{
2120 "some.other.attribute": {
2121 "type": "string",
2122 "value": "value"
2123 }
2124 }"#,
2125 )
2126 .unwrap();
2127
2128 normalize_span_category(&mut attributes);
2129
2130 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2131 {
2132 "some.other.attribute": {
2133 "type": "string",
2134 "value": "value"
2135 }
2136 }
2137 "#);
2138 }
2139}