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, sql_action_from_query,
21 sql_tables_from_query,
22};
23use crate::{ClientHints, FromUserAgentInfo as _, RawUserAgentInfo};
24
25mod ai;
26mod size;
27
28pub use self::ai::normalize_ai;
29pub use self::size::*;
30
31pub fn normalize_sentry_op(attributes: &mut Annotated<Attributes>) {
33 if attributes
34 .value()
35 .is_some_and(|attrs| attrs.contains_key(OP))
36 {
37 return;
38 }
39 let inferred_op = derive_op_for_v2_span(attributes);
40 let attrs = attributes.get_or_insert_with(Default::default);
41 attrs.insert_if_missing(OP, || inferred_op);
42}
43
44pub fn normalize_attribute_types(attributes: &mut Annotated<Attributes>) {
49 let Some(attributes) = attributes.value_mut() else {
50 return;
51 };
52
53 let attributes = attributes.0.values_mut();
54 for attribute in attributes {
55 use AttributeType::*;
56
57 let Some(inner) = attribute.value_mut() else {
58 continue;
59 };
60
61 match (&mut inner.value.ty, &mut inner.value.value) {
62 (Annotated(Some(Boolean), _), Annotated(Some(Value::Bool(_)), _)) => (),
63 (Annotated(Some(Integer), _), Annotated(Some(Value::I64(_)), _)) => (),
64 (Annotated(Some(Integer), _), Annotated(Some(Value::U64(_)), _)) => (),
65 (Annotated(Some(Double), _), Annotated(Some(Value::I64(_)), _)) => (),
66 (Annotated(Some(Double), _), Annotated(Some(Value::U64(_)), _)) => (),
67 (Annotated(Some(Double), _), Annotated(Some(Value::F64(_)), _)) => (),
68 (Annotated(Some(String), _), Annotated(Some(Value::String(_)), _)) => (),
69 (Annotated(Some(Array), _), Annotated(Some(Value::Array(arr)), _)) => {
70 if !is_supported_array(arr) {
71 let _ = attribute.value_mut().take();
72 attribute.meta_mut().add_error(ErrorKind::InvalidData);
73 }
74 }
75 (Annotated(Some(Unknown(_)), _), _) => {
81 let original = attribute.value_mut().take();
82 attribute.meta_mut().add_error(ErrorKind::InvalidData);
83 attribute.meta_mut().set_original_value(original);
84 }
85 (Annotated(Some(_), _), Annotated(Some(_), _)) => {
86 let original = attribute.value_mut().take();
87 attribute.meta_mut().add_error(ErrorKind::InvalidData);
88 attribute.meta_mut().set_original_value(original);
89 }
90 (Annotated(None, _), _) | (_, Annotated(None, _)) => {
91 let original = attribute.value_mut().take();
92 attribute.meta_mut().add_error(ErrorKind::MissingAttribute);
93 attribute.meta_mut().set_original_value(original);
94 }
95 }
96 }
97}
98
99fn is_supported_array(arr: &[Annotated<Value>]) -> bool {
103 let mut iter = arr.iter();
104
105 let Some(first) = iter.next() else {
106 return true;
108 };
109
110 let item = iter.try_fold(first, |prev, current| {
111 let r = match (prev.value(), current.value()) {
112 (None, None) => prev,
113 (None, Some(_)) => current,
114 (Some(_), None) => prev,
115 (Some(Value::String(_)), Some(Value::String(_))) => prev,
116 (Some(Value::Bool(_)), Some(Value::Bool(_))) => prev,
117 (
118 Some(Value::I64(_) | Value::U64(_) | Value::F64(_)),
120 Some(Value::I64(_) | Value::U64(_) | Value::F64(_)),
121 ) => prev,
122 (Some(_), Some(_)) => return None,
126 };
127
128 Some(r)
129 });
130
131 let Some(item) = item else {
132 return false;
134 };
135
136 matches!(
137 item.value(),
138 None | Some(
141 Value::String(_) | Value::Bool(_) | Value::I64(_) | Value::U64(_) | Value::F64(_)
142 )
143 )
144}
145
146pub fn normalize_received(attributes: &mut Annotated<Attributes>, received: DateTime<Utc>) {
148 attributes
149 .get_or_insert_with(Default::default)
150 .insert_if_missing(OBSERVED_TIMESTAMP_NANOS, || {
151 received
152 .timestamp_nanos_opt()
153 .unwrap_or_else(|| UnixTimestamp::now().as_nanos() as i64)
154 .to_string()
155 });
156}
157
158pub fn normalize_user_agent(
163 attributes: &mut Annotated<Attributes>,
164 client_user_agent: Option<&str>,
165 client_hints: ClientHints<&str>,
166) {
167 let attributes = attributes.get_or_insert_with(Default::default);
168
169 if attributes.contains_key(BROWSER_NAME) || attributes.contains_key(BROWSER_VERSION) {
170 return;
171 }
172
173 let user_agent = attributes
175 .get_value(USER_AGENT_ORIGINAL)
176 .and_then(|v| v.as_str())
177 .or(client_user_agent);
178
179 let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
180 user_agent,
181 client_hints,
182 }) else {
183 return;
184 };
185
186 attributes.insert_if_missing(BROWSER_NAME, || context.name);
187 attributes.insert_if_missing(BROWSER_VERSION, || context.version);
188}
189
190pub fn normalize_client_address(attributes: &mut Annotated<Attributes>, client_ip: Option<IpAddr>) {
198 let Some(attributes) = attributes.value_mut() else {
199 return;
200 };
201 let Some(client_ip) = client_ip else { return };
202
203 let client_address = attributes
204 .get_value(CLIENT_ADDRESS)
205 .and_then(|v| v.as_str());
206
207 if client_address == Some("{{auto}}") {
208 attributes.insert(CLIENT_ADDRESS, client_ip.to_string());
209 }
210}
211
212pub fn normalize_user_geo(
217 attributes: &mut Annotated<Attributes>,
218 info: impl FnOnce() -> Option<Geo>,
219) {
220 let attributes = attributes.get_or_insert_with(Default::default);
221
222 if [
223 USER_GEO_COUNTRY_CODE,
224 USER_GEO_CITY,
225 USER_GEO_SUBDIVISION,
226 USER_GEO_REGION,
227 ]
228 .into_iter()
229 .any(|a| attributes.contains_key(a))
230 {
231 return;
232 }
233
234 let Some(geo) = info() else {
235 return;
236 };
237
238 attributes.insert_if_missing(USER_GEO_COUNTRY_CODE, || geo.country_code);
239 attributes.insert_if_missing(USER_GEO_CITY, || geo.city);
240 attributes.insert_if_missing(USER_GEO_SUBDIVISION, || geo.subdivision);
241 attributes.insert_if_missing(USER_GEO_REGION, || geo.region);
242}
243
244pub fn normalize_dsc(attributes: &mut Annotated<Attributes>, dsc: Option<&DynamicSamplingContext>) {
246 let Some(dsc) = dsc else { return };
247
248 let attributes = attributes.get_or_insert_with(Default::default);
249
250 if attributes.contains_key(DSC_TRACE_ID) {
252 return;
253 }
254
255 attributes.insert(DSC_TRACE_ID, dsc.trace_id.to_string());
256 attributes.insert(DSC_PUBLIC_KEY, dsc.public_key.to_string());
257 if let Some(release) = &dsc.release {
258 attributes.insert(DSC_RELEASE, release.clone());
259 }
260 if let Some(environment) = &dsc.environment {
261 attributes.insert(DSC_ENVIRONMENT, environment.clone());
262 }
263 if let Some(transaction) = &dsc.transaction {
264 attributes.insert(DSC_TRANSACTION, transaction.clone());
265 }
266 if let Some(sample_rate) = dsc.sample_rate {
267 attributes.insert(DSC_SAMPLE_RATE, sample_rate);
268 }
269 if let Some(sampled) = dsc.sampled {
270 attributes.insert(DSC_SAMPLED, sampled);
271 }
272}
273
274pub fn normalize_attribute_names(attributes: &mut Annotated<Attributes>) {
283 normalize_attribute_names_inner(attributes, relay_conventions::attribute_info)
284}
285
286fn normalize_attribute_names_inner(
287 attributes: &mut Annotated<Attributes>,
288 attribute_info: fn(&str) -> Option<&'static AttributeInfo>,
289) {
290 let Some(attributes) = attributes.value_mut() else {
291 return;
292 };
293
294 let attribute_names: Vec<_> = attributes.0.keys().cloned().collect();
295
296 for name in attribute_names {
297 let Some(attribute_info) = attribute_info(&name) else {
298 continue;
299 };
300
301 match attribute_info.write_behavior {
302 WriteBehavior::CurrentName => continue,
303 WriteBehavior::NewName(new_name) => {
304 let Some(old_attribute) = attributes.0.get_mut(&name) else {
305 continue;
306 };
307
308 let mut meta = Meta::default();
309 meta.add_remark(Remark::new(RemarkType::Removed, "attribute.deprecated"));
311 let new_attribute = std::mem::replace(old_attribute, Annotated(None, meta));
312
313 if !attributes.contains_key(new_name) {
314 attributes.0.insert(new_name.to_owned(), new_attribute);
315 }
316 }
317 WriteBehavior::BothNames(new_name) => {
318 if !attributes.contains_key(new_name)
319 && let Some(current_attribute) = attributes.0.get(&name).cloned()
320 {
321 attributes.0.insert(new_name.to_owned(), current_attribute);
322 }
323 }
324 }
325 }
326}
327
328pub fn normalize_attribute_values(
337 attributes: &mut Annotated<Attributes>,
338 http_span_allowed_hosts: &[String],
339) {
340 normalize_db_attributes(attributes);
341 normalize_http_attributes(attributes, http_span_allowed_hosts);
342}
343
344fn normalize_db_attributes(annotated_attributes: &mut Annotated<Attributes>) {
353 let Some(attributes) = annotated_attributes.value() else {
354 return;
355 };
356
357 if attributes.get_value(NORMALIZED_DB_QUERY).is_some() {
359 return;
360 }
361
362 let (op, sub_op) = attributes
363 .get_value(OP)
364 .and_then(|v| v.as_str())
365 .map(|op| op.split_once('.').unwrap_or((op, "")))
366 .unwrap_or_default();
367
368 let raw_query = attributes
369 .get_value(DB_QUERY_TEXT)
370 .or_else(|| {
371 if op == "db" {
372 attributes.get_value(DESCRIPTION)
373 } else {
374 None
375 }
376 })
377 .and_then(|v| v.as_str());
378
379 let db_system = attributes
380 .get_value(DB_SYSTEM_NAME)
381 .and_then(|v| v.as_str());
382
383 let db_operation = attributes
384 .get_value(DB_OPERATION_NAME)
385 .and_then(|v| v.as_str());
386
387 let collection_name = attributes
388 .get_value(DB_COLLECTION_NAME)
389 .and_then(|v| v.as_str());
390
391 let span_origin = attributes.get_value(ORIGIN).and_then(|v| v.as_str());
392
393 let (normalized_db_query, parsed_sql) = if let Some(raw_query) = raw_query {
394 scrub_db_query(
395 raw_query,
396 sub_op,
397 db_system,
398 db_operation,
399 collection_name,
400 span_origin,
401 )
402 } else {
403 (None, None)
404 };
405
406 let db_operation = if db_operation.is_none() {
407 if sub_op == "redis" || db_system == Some("redis") {
408 if let Some(query) = normalized_db_query.as_ref() {
410 let command = query.replace(" *", "");
411 if command.is_empty() {
412 None
413 } else {
414 Some(command)
415 }
416 } else {
417 None
418 }
419 } else if let Some(raw_query) = raw_query {
420 sql_action_from_query(raw_query).map(|a| a.to_uppercase())
422 } else {
423 None
424 }
425 } else {
426 db_operation.map(|db_operation| db_operation.to_uppercase())
427 };
428
429 let db_collection_name: Option<String> = if let Some(name) = collection_name {
430 if db_system == Some("mongodb") {
431 match TABLE_NAME_REGEX.replace_all(name, "{%s}") {
432 Cow::Owned(s) => Some(s),
433 Cow::Borrowed(_) => Some(name.to_owned()),
434 }
435 } else {
436 Some(name.to_owned())
437 }
438 } else if span_origin == Some("auto.db.supabase") {
439 normalized_db_query
440 .as_ref()
441 .and_then(|query| query.strip_prefix("from("))
442 .and_then(|s| s.strip_suffix(")"))
443 .map(String::from)
444 } else if let Some(raw_query) = raw_query {
445 sql_tables_from_query(raw_query, &parsed_sql)
446 } else {
447 None
448 };
449
450 if let Some(attributes) = annotated_attributes.value_mut() {
451 if let Some(normalized_db_query) = normalized_db_query {
452 let mut normalized_db_query_hash = format!("{:x}", md5::compute(&normalized_db_query));
453 normalized_db_query_hash.truncate(16);
454
455 attributes.insert(NORMALIZED_DB_QUERY, normalized_db_query);
456 attributes.insert(NORMALIZED_DB_QUERY_HASH, normalized_db_query_hash);
457 }
458 if let Some(db_operation_name) = db_operation {
459 attributes.insert(DB_OPERATION_NAME, db_operation_name)
460 }
461 if let Some(db_collection_name) = db_collection_name {
462 attributes.insert(DB_COLLECTION_NAME, db_collection_name);
463 }
464 }
465}
466
467fn normalize_http_attributes(
472 annotated_attributes: &mut Annotated<Attributes>,
473 allowed_hosts: &[String],
474) {
475 let Some(attributes) = annotated_attributes.value() else {
476 return;
477 };
478
479 if !attributes.contains_key(HTTP_REQUEST_METHOD)
482 && !attributes.contains_key(LEGACY_HTTP_REQUEST_METHOD)
483 {
484 return;
485 }
486
487 let op = attributes.get_value(OP).and_then(|v| v.as_str());
488
489 let method = attributes
490 .get_value(HTTP_REQUEST_METHOD)
491 .or_else(|| attributes.get_value(LEGACY_HTTP_REQUEST_METHOD))
492 .and_then(|v| v.as_str());
493
494 let server_address = attributes
495 .get_value(SERVER_ADDRESS)
496 .and_then(|v| v.as_str());
497
498 let url: Option<&str> = attributes
499 .get_value(URL_FULL)
500 .and_then(|v| v.as_str())
501 .or_else(|| {
502 attributes
503 .get_value(DESCRIPTION)
504 .and_then(|v| v.as_str())
505 .and_then(|description| description.split_once(' ').map(|(_, url)| url))
506 });
507 let url_scheme = attributes.get_value(URL_SCHEME).and_then(|v| v.as_str());
508
509 let (normalized_server_address, raw_url) = if op == Some("http.client") {
512 let domain_from_scrubbed_http = method
513 .zip(url)
514 .and_then(|(method, url)| scrub_http(method, url, allowed_hosts))
515 .and_then(|scrubbed_http| domain_from_scrubbed_http(&scrubbed_http));
516
517 if let Some(domain) = domain_from_scrubbed_http {
518 (Some(domain), url.map(String::from))
519 } else {
520 domain_from_server_address(server_address, url_scheme)
521 }
522 } else {
523 (None, None)
524 };
525
526 let method = method.map(|m| m.to_uppercase());
527
528 if let Some(attributes) = annotated_attributes.value_mut() {
529 if let Some(method) = method {
530 attributes.insert(HTTP_REQUEST_METHOD, method);
531 }
532
533 if let Some(normalized_server_address) = normalized_server_address {
534 attributes.insert(SERVER_ADDRESS, normalized_server_address);
535 }
536
537 if let Some(raw_url) = raw_url {
538 attributes.insert_if_missing(URL_FULL, || raw_url);
539 }
540 }
541}
542
543pub fn write_legacy_attributes(attributes: &mut Annotated<Attributes>) {
551 let Some(attributes) = attributes.value_mut() else {
552 return;
553 };
554
555 let current_to_legacy_attributes = [
557 (NORMALIZED_DB_QUERY, SENTRY_NORMALIZED_DESCRIPTION),
559 (NORMALIZED_DB_QUERY_HASH, SENTRY_GROUP),
560 (DB_OPERATION_NAME, SENTRY_ACTION),
561 (DB_COLLECTION_NAME, SENTRY_DOMAIN),
562 (SERVER_ADDRESS, SENTRY_DOMAIN),
564 (HTTP_REQUEST_METHOD, SENTRY_ACTION),
565 ];
566
567 for (current_attribute, legacy_attribute) in current_to_legacy_attributes {
568 if attributes.contains_key(current_attribute) {
569 let Some(attr) = attributes.get_attribute(current_attribute) else {
570 continue;
571 };
572 attributes.insert(legacy_attribute, attr.value.clone());
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use relay_protocol::SerializableAnnotated;
580
581 use super::*;
582
583 #[test]
584 fn test_normalize_received_none() {
585 let mut attributes = Default::default();
586
587 normalize_received(
588 &mut attributes,
589 DateTime::from_timestamp_nanos(1_234_201_337),
590 );
591
592 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
593 {
594 "sentry.observed_timestamp_nanos": {
595 "type": "string",
596 "value": "1234201337"
597 }
598 }
599 "#);
600 }
601
602 #[test]
603 fn test_normalize_received_existing() {
604 let mut attributes = Annotated::from_json(
605 r#"{
606 "sentry.observed_timestamp_nanos": {
607 "type": "string",
608 "value": "111222333"
609 }
610 }"#,
611 )
612 .unwrap();
613
614 normalize_received(
615 &mut attributes,
616 DateTime::from_timestamp_nanos(1_234_201_337),
617 );
618
619 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
620 {
621 "sentry.observed_timestamp_nanos": {
622 "type": "string",
623 "value": "111222333"
624 }
625 }
626 "###);
627 }
628
629 #[test]
630 fn test_process_attribute_types() {
631 let json = r#"{
632 "valid_bool": {
633 "type": "boolean",
634 "value": true
635 },
636 "valid_int_i64": {
637 "type": "integer",
638 "value": -42
639 },
640 "valid_int_u64": {
641 "type": "integer",
642 "value": 42
643 },
644 "valid_int_from_string": {
645 "type": "integer",
646 "value": "42"
647 },
648 "valid_double": {
649 "type": "double",
650 "value": 42.5
651 },
652 "double_with_i64": {
653 "type": "double",
654 "value": -42
655 },
656 "valid_double_with_u64": {
657 "type": "double",
658 "value": 42
659 },
660 "valid_string": {
661 "type": "string",
662 "value": "test"
663 },
664 "valid_string_with_other": {
665 "type": "string",
666 "value": "test",
667 "some_other_field": "some_other_value"
668 },
669 "unknown_type": {
670 "type": "custom",
671 "value": "test"
672 },
673 "invalid_int_from_invalid_string": {
674 "type": "integer",
675 "value": "abc"
676 },
677 "missing_type": {
678 "value": "value with missing type"
679 },
680 "missing_value": {
681 "type": "string"
682 },
683 "supported_array_string": {
684 "type": "array",
685 "value": ["foo", "bar"]
686 },
687 "supported_array_double": {
688 "type": "array",
689 "value": [3, 3.0, 3]
690 },
691 "supported_array_null": {
692 "type": "array",
693 "value": [null, null]
694 },
695 "unsupported_array_mixed": {
696 "type": "array",
697 "value": ["foo", 1.0]
698 },
699 "unsupported_array_object": {
700 "type": "array",
701 "value": [{}]
702 },
703 "unsupported_array_in_array": {
704 "type": "array",
705 "value": [[]]
706 }
707 }"#;
708
709 let mut attributes = Annotated::<Attributes>::from_json(json).unwrap();
710 normalize_attribute_types(&mut attributes);
711
712 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
713 {
714 "double_with_i64": {
715 "type": "double",
716 "value": -42
717 },
718 "invalid_int_from_invalid_string": null,
719 "missing_type": null,
720 "missing_value": null,
721 "supported_array_double": {
722 "type": "array",
723 "value": [
724 3,
725 3.0,
726 3
727 ]
728 },
729 "supported_array_null": {
730 "type": "array",
731 "value": [
732 null,
733 null
734 ]
735 },
736 "supported_array_string": {
737 "type": "array",
738 "value": [
739 "foo",
740 "bar"
741 ]
742 },
743 "unknown_type": null,
744 "unsupported_array_in_array": null,
745 "unsupported_array_mixed": null,
746 "unsupported_array_object": null,
747 "valid_bool": {
748 "type": "boolean",
749 "value": true
750 },
751 "valid_double": {
752 "type": "double",
753 "value": 42.5
754 },
755 "valid_double_with_u64": {
756 "type": "double",
757 "value": 42
758 },
759 "valid_int_from_string": null,
760 "valid_int_i64": {
761 "type": "integer",
762 "value": -42
763 },
764 "valid_int_u64": {
765 "type": "integer",
766 "value": 42
767 },
768 "valid_string": {
769 "type": "string",
770 "value": "test"
771 },
772 "valid_string_with_other": {
773 "type": "string",
774 "value": "test",
775 "some_other_field": "some_other_value"
776 },
777 "_meta": {
778 "invalid_int_from_invalid_string": {
779 "": {
780 "err": [
781 "invalid_data"
782 ],
783 "val": {
784 "type": "integer",
785 "value": "abc"
786 }
787 }
788 },
789 "missing_type": {
790 "": {
791 "err": [
792 "missing_attribute"
793 ],
794 "val": {
795 "type": null,
796 "value": "value with missing type"
797 }
798 }
799 },
800 "missing_value": {
801 "": {
802 "err": [
803 "missing_attribute"
804 ],
805 "val": {
806 "type": "string",
807 "value": null
808 }
809 }
810 },
811 "unknown_type": {
812 "": {
813 "err": [
814 "invalid_data"
815 ],
816 "val": {
817 "type": "custom",
818 "value": "test"
819 }
820 }
821 },
822 "unsupported_array_in_array": {
823 "": {
824 "err": [
825 "invalid_data"
826 ]
827 }
828 },
829 "unsupported_array_mixed": {
830 "": {
831 "err": [
832 "invalid_data"
833 ]
834 }
835 },
836 "unsupported_array_object": {
837 "": {
838 "err": [
839 "invalid_data"
840 ]
841 }
842 },
843 "valid_int_from_string": {
844 "": {
845 "err": [
846 "invalid_data"
847 ],
848 "val": {
849 "type": "integer",
850 "value": "42"
851 }
852 }
853 }
854 }
855 }
856 "#);
857 }
858
859 #[test]
860 fn test_normalize_user_agent_none() {
861 let mut attributes = Default::default();
862 normalize_user_agent(
863 &mut attributes,
864 Some(
865 "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",
866 ),
867 ClientHints::default(),
868 );
869
870 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
871 {
872 "sentry.browser.name": {
873 "type": "string",
874 "value": "Chrome"
875 },
876 "sentry.browser.version": {
877 "type": "string",
878 "value": "131.0.0"
879 }
880 }
881 "#);
882 }
883
884 #[test]
885 fn test_normalize_user_agent_existing() {
886 let mut attributes = Annotated::from_json(
887 r#"{
888 "sentry.browser.name": {
889 "type": "string",
890 "value": "Very Special"
891 },
892 "sentry.browser.version": {
893 "type": "string",
894 "value": "13.3.7"
895 }
896 }"#,
897 )
898 .unwrap();
899
900 normalize_user_agent(
901 &mut attributes,
902 Some(
903 "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",
904 ),
905 ClientHints::default(),
906 );
907
908 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
909 {
910 "sentry.browser.name": {
911 "type": "string",
912 "value": "Very Special"
913 },
914 "sentry.browser.version": {
915 "type": "string",
916 "value": "13.3.7"
917 }
918 }
919 "#,
920 );
921 }
922
923 #[test]
924 fn test_normalize_user_geo_none() {
925 let mut attributes = Default::default();
926
927 normalize_user_geo(&mut attributes, || {
928 Some(Geo {
929 country_code: "XY".to_owned().into(),
930 city: "Foo Hausen".to_owned().into(),
931 subdivision: Annotated::empty(),
932 region: "Illu".to_owned().into(),
933 other: Default::default(),
934 })
935 });
936
937 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
938 {
939 "user.geo.city": {
940 "type": "string",
941 "value": "Foo Hausen"
942 },
943 "user.geo.country_code": {
944 "type": "string",
945 "value": "XY"
946 },
947 "user.geo.region": {
948 "type": "string",
949 "value": "Illu"
950 }
951 }
952 "#);
953 }
954
955 #[test]
956 fn test_normalize_user_geo_existing() {
957 let mut attributes = Annotated::from_json(
958 r#"{
959 "user.geo.city": {
960 "type": "string",
961 "value": "Foo Hausen"
962 }
963 }"#,
964 )
965 .unwrap();
966
967 normalize_user_geo(&mut attributes, || unreachable!());
968
969 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
970 {
971 "user.geo.city": {
972 "type": "string",
973 "value": "Foo Hausen"
974 }
975 }
976 "#,
977 );
978 }
979
980 #[test]
981 fn test_normalize_attributes() {
982 fn mock_attribute_info(name: &str) -> Option<&'static AttributeInfo> {
983 use relay_conventions::Pii;
984
985 match name {
986 "replace.empty" => Some(&AttributeInfo {
987 write_behavior: WriteBehavior::NewName("replaced"),
988 pii: Pii::Maybe,
989 aliases: &["replaced"],
990 }),
991 "replace.existing" => Some(&AttributeInfo {
992 write_behavior: WriteBehavior::NewName("not.replaced"),
993 pii: Pii::Maybe,
994 aliases: &["not.replaced"],
995 }),
996 "backfill.empty" => Some(&AttributeInfo {
997 write_behavior: WriteBehavior::BothNames("backfilled"),
998 pii: Pii::Maybe,
999 aliases: &["backfilled"],
1000 }),
1001 "backfill.existing" => Some(&AttributeInfo {
1002 write_behavior: WriteBehavior::BothNames("not.backfilled"),
1003 pii: Pii::Maybe,
1004 aliases: &["not.backfilled"],
1005 }),
1006 _ => None,
1007 }
1008 }
1009
1010 let mut attributes = Annotated::new(Attributes::from([
1011 (
1012 "replace.empty".to_owned(),
1013 Annotated::new("Should be moved".to_owned().into()),
1014 ),
1015 (
1016 "replace.existing".to_owned(),
1017 Annotated::new("Should be removed".to_owned().into()),
1018 ),
1019 (
1020 "not.replaced".to_owned(),
1021 Annotated::new("Should be left alone".to_owned().into()),
1022 ),
1023 (
1024 "backfill.empty".to_owned(),
1025 Annotated::new("Should be copied".to_owned().into()),
1026 ),
1027 (
1028 "backfill.existing".to_owned(),
1029 Annotated::new("Should be left alone".to_owned().into()),
1030 ),
1031 (
1032 "not.backfilled".to_owned(),
1033 Annotated::new("Should be left alone".to_owned().into()),
1034 ),
1035 ]));
1036
1037 normalize_attribute_names_inner(&mut attributes, mock_attribute_info);
1038
1039 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
1040 {
1041 "backfill.empty": {
1042 "type": "string",
1043 "value": "Should be copied"
1044 },
1045 "backfill.existing": {
1046 "type": "string",
1047 "value": "Should be left alone"
1048 },
1049 "backfilled": {
1050 "type": "string",
1051 "value": "Should be copied"
1052 },
1053 "not.backfilled": {
1054 "type": "string",
1055 "value": "Should be left alone"
1056 },
1057 "not.replaced": {
1058 "type": "string",
1059 "value": "Should be left alone"
1060 },
1061 "replace.empty": null,
1062 "replace.existing": null,
1063 "replaced": {
1064 "type": "string",
1065 "value": "Should be moved"
1066 },
1067 "_meta": {
1068 "replace.empty": {
1069 "": {
1070 "rem": [
1071 [
1072 "attribute.deprecated",
1073 "x"
1074 ]
1075 ]
1076 }
1077 },
1078 "replace.existing": {
1079 "": {
1080 "rem": [
1081 [
1082 "attribute.deprecated",
1083 "x"
1084 ]
1085 ]
1086 }
1087 }
1088 }
1089 }
1090 "###);
1091 }
1092
1093 #[test]
1094 fn test_normalize_span_infers_op() {
1095 let mut attributes = Annotated::<Attributes>::from_json(
1096 r#"{
1097 "db.system.name": {
1098 "type": "string",
1099 "value": "mysql"
1100 },
1101 "db.operation.name": {
1102 "type": "string",
1103 "value": "query"
1104 }
1105 }
1106 "#,
1107 )
1108 .unwrap();
1109
1110 normalize_sentry_op(&mut attributes);
1111
1112 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1113 {
1114 "db.operation.name": {
1115 "type": "string",
1116 "value": "query"
1117 },
1118 "db.system.name": {
1119 "type": "string",
1120 "value": "mysql"
1121 },
1122 "sentry.op": {
1123 "type": "string",
1124 "value": "db"
1125 }
1126 }
1127 "#);
1128 }
1129
1130 #[test]
1131 fn test_normalize_attribute_values_mysql_db_query_attributes() {
1132 let mut attributes = Annotated::<Attributes>::from_json(
1133 r#"
1134 {
1135 "sentry.op": {
1136 "type": "string",
1137 "value": "db.query"
1138 },
1139 "sentry.origin": {
1140 "type": "string",
1141 "value": "auto.otlp.spans"
1142 },
1143 "db.system.name": {
1144 "type": "string",
1145 "value": "mysql"
1146 },
1147 "db.query.text": {
1148 "type": "string",
1149 "value": "SELECT \"not an identifier\""
1150 }
1151 }
1152 "#,
1153 )
1154 .unwrap();
1155
1156 normalize_db_attributes(&mut attributes);
1157
1158 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1159 {
1160 "db.operation.name": {
1161 "type": "string",
1162 "value": "SELECT"
1163 },
1164 "db.query.text": {
1165 "type": "string",
1166 "value": "SELECT \"not an identifier\""
1167 },
1168 "db.system.name": {
1169 "type": "string",
1170 "value": "mysql"
1171 },
1172 "sentry.normalized_db_query": {
1173 "type": "string",
1174 "value": "SELECT %s"
1175 },
1176 "sentry.normalized_db_query.hash": {
1177 "type": "string",
1178 "value": "3a377dcc490b1690"
1179 },
1180 "sentry.op": {
1181 "type": "string",
1182 "value": "db.query"
1183 },
1184 "sentry.origin": {
1185 "type": "string",
1186 "value": "auto.otlp.spans"
1187 }
1188 }
1189 "#);
1190 }
1191
1192 #[test]
1193 fn test_normalize_mongodb_db_query_attributes() {
1194 let mut attributes = Annotated::<Attributes>::from_json(
1195 r#"
1196 {
1197 "sentry.op": {
1198 "type": "string",
1199 "value": "db"
1200 },
1201 "db.system.name": {
1202 "type": "string",
1203 "value": "mongodb"
1204 },
1205 "db.query.text": {
1206 "type": "string",
1207 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1208 },
1209 "db.operation.name": {
1210 "type": "string",
1211 "value": "find"
1212 },
1213 "db.collection.name": {
1214 "type": "string",
1215 "value": "documents"
1216 }
1217 }
1218 "#,
1219 )
1220 .unwrap();
1221
1222 normalize_db_attributes(&mut attributes);
1223
1224 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1225 {
1226 "db.collection.name": {
1227 "type": "string",
1228 "value": "documents"
1229 },
1230 "db.operation.name": {
1231 "type": "string",
1232 "value": "FIND"
1233 },
1234 "db.query.text": {
1235 "type": "string",
1236 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1237 },
1238 "db.system.name": {
1239 "type": "string",
1240 "value": "mongodb"
1241 },
1242 "sentry.normalized_db_query": {
1243 "type": "string",
1244 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1245 },
1246 "sentry.normalized_db_query.hash": {
1247 "type": "string",
1248 "value": "aedc5c7e8cec726b"
1249 },
1250 "sentry.op": {
1251 "type": "string",
1252 "value": "db"
1253 }
1254 }
1255 "#);
1256 }
1257
1258 #[test]
1259 fn test_normalize_db_attributes_does_not_update_attributes_if_already_normalized() {
1260 let mut attributes = Annotated::<Attributes>::from_json(
1261 r#"
1262 {
1263 "db.collection.name": {
1264 "type": "string",
1265 "value": "documents"
1266 },
1267 "db.operation.name": {
1268 "type": "string",
1269 "value": "FIND"
1270 },
1271 "db.query.text": {
1272 "type": "string",
1273 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1274 },
1275 "db.system.name": {
1276 "type": "string",
1277 "value": "mongodb"
1278 },
1279 "sentry.normalized_db_query": {
1280 "type": "string",
1281 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1282 },
1283 "sentry.op": {
1284 "type": "string",
1285 "value": "db"
1286 }
1287 }
1288 "#,
1289 )
1290 .unwrap();
1291
1292 normalize_db_attributes(&mut attributes);
1293
1294 insta::assert_json_snapshot!(
1295 SerializableAnnotated(&attributes), @r#"
1296 {
1297 "db.collection.name": {
1298 "type": "string",
1299 "value": "documents"
1300 },
1301 "db.operation.name": {
1302 "type": "string",
1303 "value": "FIND"
1304 },
1305 "db.query.text": {
1306 "type": "string",
1307 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1308 },
1309 "db.system.name": {
1310 "type": "string",
1311 "value": "mongodb"
1312 },
1313 "sentry.normalized_db_query": {
1314 "type": "string",
1315 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1316 },
1317 "sentry.op": {
1318 "type": "string",
1319 "value": "db"
1320 }
1321 }
1322 "#
1323 );
1324 }
1325
1326 #[test]
1327 fn test_normalize_db_attributes_does_not_change_non_db_spans() {
1328 let mut attributes = Annotated::<Attributes>::from_json(
1329 r#"
1330 {
1331 "sentry.op": {
1332 "type": "string",
1333 "value": "http.client"
1334 },
1335 "sentry.origin": {
1336 "type": "string",
1337 "value": "auto.otlp.spans"
1338 },
1339 "http.request.method": {
1340 "type": "string",
1341 "value": "GET"
1342 }
1343 }
1344 "#,
1345 )
1346 .unwrap();
1347
1348 normalize_db_attributes(&mut attributes);
1349
1350 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1351 {
1352 "http.request.method": {
1353 "type": "string",
1354 "value": "GET"
1355 },
1356 "sentry.op": {
1357 "type": "string",
1358 "value": "http.client"
1359 },
1360 "sentry.origin": {
1361 "type": "string",
1362 "value": "auto.otlp.spans"
1363 }
1364 }
1365 "#);
1366 }
1367
1368 #[test]
1369 fn test_normalize_http_attributes() {
1370 let mut attributes = Annotated::<Attributes>::from_json(
1371 r#"
1372 {
1373 "sentry.op": {
1374 "type": "string",
1375 "value": "http.client"
1376 },
1377 "http.request.method": {
1378 "type": "string",
1379 "value": "GET"
1380 },
1381 "url.full": {
1382 "type": "string",
1383 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1384 }
1385 }
1386 "#,
1387 )
1388 .unwrap();
1389
1390 normalize_http_attributes(&mut attributes, &[]);
1391
1392 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1393 {
1394 "http.request.method": {
1395 "type": "string",
1396 "value": "GET"
1397 },
1398 "sentry.op": {
1399 "type": "string",
1400 "value": "http.client"
1401 },
1402 "server.address": {
1403 "type": "string",
1404 "value": "*.xn--85x722f.xn--55qx5d.cn"
1405 },
1406 "url.full": {
1407 "type": "string",
1408 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1409 }
1410 }
1411 "#);
1412 }
1413
1414 #[test]
1415 fn test_normalize_http_attributes_server_address() {
1416 let mut attributes = Annotated::<Attributes>::from_json(
1417 r#"
1418 {
1419 "sentry.op": {
1420 "type": "string",
1421 "value": "http.client"
1422 },
1423 "url.scheme": {
1424 "type": "string",
1425 "value": "https"
1426 },
1427 "server.address": {
1428 "type": "string",
1429 "value": "subdomain.example.com:5688"
1430 },
1431 "http.request.method": {
1432 "type": "string",
1433 "value": "GET"
1434 }
1435 }
1436 "#,
1437 )
1438 .unwrap();
1439
1440 normalize_http_attributes(&mut attributes, &[]);
1441
1442 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1443 {
1444 "http.request.method": {
1445 "type": "string",
1446 "value": "GET"
1447 },
1448 "sentry.op": {
1449 "type": "string",
1450 "value": "http.client"
1451 },
1452 "server.address": {
1453 "type": "string",
1454 "value": "*.example.com:5688"
1455 },
1456 "url.full": {
1457 "type": "string",
1458 "value": "https://subdomain.example.com:5688"
1459 },
1460 "url.scheme": {
1461 "type": "string",
1462 "value": "https"
1463 }
1464 }
1465 "#);
1466 }
1467
1468 #[test]
1469 fn test_normalize_http_attributes_allowed_hosts() {
1470 let mut attributes = Annotated::<Attributes>::from_json(
1471 r#"
1472 {
1473 "sentry.op": {
1474 "type": "string",
1475 "value": "http.client"
1476 },
1477 "http.request.method": {
1478 "type": "string",
1479 "value": "GET"
1480 },
1481 "url.full": {
1482 "type": "string",
1483 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1484 }
1485 }
1486 "#,
1487 )
1488 .unwrap();
1489
1490 normalize_http_attributes(
1491 &mut attributes,
1492 &["application.www.xn--85x722f.xn--55qx5d.cn".to_owned()],
1493 );
1494
1495 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1496 {
1497 "http.request.method": {
1498 "type": "string",
1499 "value": "GET"
1500 },
1501 "sentry.op": {
1502 "type": "string",
1503 "value": "http.client"
1504 },
1505 "server.address": {
1506 "type": "string",
1507 "value": "application.www.xn--85x722f.xn--55qx5d.cn"
1508 },
1509 "url.full": {
1510 "type": "string",
1511 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1512 }
1513 }
1514 "#);
1515 }
1516
1517 #[test]
1518 fn test_normalize_db_attributes_from_legacy_attributes() {
1519 let mut attributes = Annotated::<Attributes>::from_json(
1520 r#"
1521 {
1522 "sentry.op": {
1523 "type": "string",
1524 "value": "db"
1525 },
1526 "db.system.name": {
1527 "type": "string",
1528 "value": "mongodb"
1529 },
1530 "sentry.description": {
1531 "type": "string",
1532 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1533 },
1534 "db.operation.name": {
1535 "type": "string",
1536 "value": "find"
1537 },
1538 "db.collection.name": {
1539 "type": "string",
1540 "value": "documents"
1541 }
1542 }
1543 "#,
1544 )
1545 .unwrap();
1546
1547 normalize_db_attributes(&mut attributes);
1548
1549 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1550 {
1551 "db.collection.name": {
1552 "type": "string",
1553 "value": "documents"
1554 },
1555 "db.operation.name": {
1556 "type": "string",
1557 "value": "FIND"
1558 },
1559 "db.system.name": {
1560 "type": "string",
1561 "value": "mongodb"
1562 },
1563 "sentry.description": {
1564 "type": "string",
1565 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1566 },
1567 "sentry.normalized_db_query": {
1568 "type": "string",
1569 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1570 },
1571 "sentry.normalized_db_query.hash": {
1572 "type": "string",
1573 "value": "aedc5c7e8cec726b"
1574 },
1575 "sentry.op": {
1576 "type": "string",
1577 "value": "db"
1578 }
1579 }
1580 "#);
1581 }
1582
1583 #[test]
1584 fn test_normalize_http_attributes_from_legacy_attributes() {
1585 let mut attributes = Annotated::<Attributes>::from_json(
1586 r#"
1587 {
1588 "sentry.op": {
1589 "type": "string",
1590 "value": "http.client"
1591 },
1592 "http.request_method": {
1593 "type": "string",
1594 "value": "GET"
1595 },
1596 "sentry.description": {
1597 "type": "string",
1598 "value": "GET https://application.www.xn--85x722f.xn--55qx5d.cn"
1599 }
1600 }
1601 "#,
1602 )
1603 .unwrap();
1604
1605 normalize_http_attributes(&mut attributes, &[]);
1606
1607 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1608 {
1609 "http.request.method": {
1610 "type": "string",
1611 "value": "GET"
1612 },
1613 "http.request_method": {
1614 "type": "string",
1615 "value": "GET"
1616 },
1617 "sentry.description": {
1618 "type": "string",
1619 "value": "GET https://application.www.xn--85x722f.xn--55qx5d.cn"
1620 },
1621 "sentry.op": {
1622 "type": "string",
1623 "value": "http.client"
1624 },
1625 "server.address": {
1626 "type": "string",
1627 "value": "*.xn--85x722f.xn--55qx5d.cn"
1628 },
1629 "url.full": {
1630 "type": "string",
1631 "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
1632 }
1633 }
1634 "#);
1635 }
1636
1637 #[test]
1638 fn test_write_legacy_attributes() {
1639 let mut attributes = Annotated::<Attributes>::from_json(
1640 r#"
1641 {
1642 "db.collection.name": {
1643 "type": "string",
1644 "value": "documents"
1645 },
1646 "db.operation.name": {
1647 "type": "string",
1648 "value": "FIND"
1649 },
1650 "db.query.text": {
1651 "type": "string",
1652 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1653 },
1654 "db.system.name": {
1655 "type": "string",
1656 "value": "mongodb"
1657 },
1658 "sentry.normalized_db_query": {
1659 "type": "string",
1660 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1661 },
1662 "sentry.normalized_db_query.hash": {
1663 "type": "string",
1664 "value": "aedc5c7e8cec726b"
1665 },
1666 "sentry.op": {
1667 "type": "string",
1668 "value": "db"
1669 }
1670 }
1671 "#,
1672 )
1673 .unwrap();
1674
1675 write_legacy_attributes(&mut attributes);
1676
1677 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1678 {
1679 "db.collection.name": {
1680 "type": "string",
1681 "value": "documents"
1682 },
1683 "db.operation.name": {
1684 "type": "string",
1685 "value": "FIND"
1686 },
1687 "db.query.text": {
1688 "type": "string",
1689 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1690 },
1691 "db.system.name": {
1692 "type": "string",
1693 "value": "mongodb"
1694 },
1695 "sentry.action": {
1696 "type": "string",
1697 "value": "FIND"
1698 },
1699 "sentry.domain": {
1700 "type": "string",
1701 "value": "documents"
1702 },
1703 "sentry.group": {
1704 "type": "string",
1705 "value": "aedc5c7e8cec726b"
1706 },
1707 "sentry.normalized_db_query": {
1708 "type": "string",
1709 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1710 },
1711 "sentry.normalized_db_query.hash": {
1712 "type": "string",
1713 "value": "aedc5c7e8cec726b"
1714 },
1715 "sentry.normalized_description": {
1716 "type": "string",
1717 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1718 },
1719 "sentry.op": {
1720 "type": "string",
1721 "value": "db"
1722 }
1723 }
1724 "#);
1725 }
1726}