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;
19use crate::span::tag_extraction::{sql_action_from_query, sql_tables_from_query};
20use crate::{ClientHints, FromUserAgentInfo as _, RawUserAgentInfo};
21
22mod ai;
23
24pub use self::ai::normalize_ai;
25
26pub fn normalize_sentry_op(attributes: &mut Annotated<Attributes>) {
28 if attributes
29 .value()
30 .is_some_and(|attrs| attrs.contains_key(OP))
31 {
32 return;
33 }
34 let inferred_op = derive_op_for_v2_span(attributes);
35 let attrs = attributes.get_or_insert_with(Default::default);
36 attrs.insert_if_missing(OP, || inferred_op);
37}
38
39pub fn normalize_attribute_types(attributes: &mut Annotated<Attributes>) {
44 let Some(attributes) = attributes.value_mut() else {
45 return;
46 };
47
48 let attributes = attributes.0.values_mut();
49 for attribute in attributes {
50 use AttributeType::*;
51
52 let Some(inner) = attribute.value_mut() else {
53 continue;
54 };
55
56 match (&mut inner.value.ty, &mut inner.value.value) {
57 (Annotated(Some(Boolean), _), Annotated(Some(Value::Bool(_)), _)) => (),
58 (Annotated(Some(Integer), _), Annotated(Some(Value::I64(_)), _)) => (),
59 (Annotated(Some(Integer), _), Annotated(Some(Value::U64(_)), _)) => (),
60 (Annotated(Some(Double), _), Annotated(Some(Value::I64(_)), _)) => (),
61 (Annotated(Some(Double), _), Annotated(Some(Value::U64(_)), _)) => (),
62 (Annotated(Some(Double), _), Annotated(Some(Value::F64(_)), _)) => (),
63 (Annotated(Some(String), _), Annotated(Some(Value::String(_)), _)) => (),
64 (Annotated(Some(Unknown(_)), _), _) => {
70 let original = attribute.value_mut().take();
71 attribute.meta_mut().add_error(ErrorKind::InvalidData);
72 attribute.meta_mut().set_original_value(original);
73 }
74 (Annotated(Some(_), _), Annotated(Some(_), _)) => {
75 let original = attribute.value_mut().take();
76 attribute.meta_mut().add_error(ErrorKind::InvalidData);
77 attribute.meta_mut().set_original_value(original);
78 }
79 (Annotated(None, _), _) | (_, Annotated(None, _)) => {
80 let original = attribute.value_mut().take();
81 attribute.meta_mut().add_error(ErrorKind::MissingAttribute);
82 attribute.meta_mut().set_original_value(original);
83 }
84 }
85 }
86}
87
88pub fn normalize_received(attributes: &mut Annotated<Attributes>, received: DateTime<Utc>) {
90 attributes
91 .get_or_insert_with(Default::default)
92 .insert_if_missing(OBSERVED_TIMESTAMP_NANOS, || {
93 received
94 .timestamp_nanos_opt()
95 .unwrap_or_else(|| UnixTimestamp::now().as_nanos() as i64)
96 .to_string()
97 });
98}
99
100pub fn normalize_user_agent(
105 attributes: &mut Annotated<Attributes>,
106 client_user_agent: Option<&str>,
107 client_hints: ClientHints<&str>,
108) {
109 let attributes = attributes.get_or_insert_with(Default::default);
110
111 if attributes.contains_key(BROWSER_NAME) || attributes.contains_key(BROWSER_VERSION) {
112 return;
113 }
114
115 let user_agent = attributes
117 .get_value(USER_AGENT_ORIGINAL)
118 .and_then(|v| v.as_str())
119 .or(client_user_agent);
120
121 let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
122 user_agent,
123 client_hints,
124 }) else {
125 return;
126 };
127
128 attributes.insert_if_missing(BROWSER_NAME, || context.name);
129 attributes.insert_if_missing(BROWSER_VERSION, || context.version);
130}
131
132pub fn normalize_client_address(attributes: &mut Annotated<Attributes>, client_ip: Option<IpAddr>) {
140 let Some(attributes) = attributes.value_mut() else {
141 return;
142 };
143 let Some(client_ip) = client_ip else { return };
144
145 let client_address = attributes
146 .get_value(CLIENT_ADDRESS)
147 .and_then(|v| v.as_str());
148
149 if client_address == Some("{{auto}}") {
150 attributes.insert(CLIENT_ADDRESS, client_ip.to_string());
151 }
152}
153
154pub fn normalize_user_geo(
159 attributes: &mut Annotated<Attributes>,
160 info: impl FnOnce() -> Option<Geo>,
161) {
162 let attributes = attributes.get_or_insert_with(Default::default);
163
164 if [
165 USER_GEO_COUNTRY_CODE,
166 USER_GEO_CITY,
167 USER_GEO_SUBDIVISION,
168 USER_GEO_REGION,
169 ]
170 .into_iter()
171 .any(|a| attributes.contains_key(a))
172 {
173 return;
174 }
175
176 let Some(geo) = info() else {
177 return;
178 };
179
180 attributes.insert_if_missing(USER_GEO_COUNTRY_CODE, || geo.country_code);
181 attributes.insert_if_missing(USER_GEO_CITY, || geo.city);
182 attributes.insert_if_missing(USER_GEO_SUBDIVISION, || geo.subdivision);
183 attributes.insert_if_missing(USER_GEO_REGION, || geo.region);
184}
185
186pub fn normalize_dsc(attributes: &mut Annotated<Attributes>, dsc: Option<&DynamicSamplingContext>) {
188 let Some(dsc) = dsc else { return };
189
190 let attributes = attributes.get_or_insert_with(Default::default);
191
192 if attributes.contains_key(DSC_TRACE_ID) {
194 return;
195 }
196
197 attributes.insert(DSC_TRACE_ID, dsc.trace_id.to_string());
198 attributes.insert(DSC_PUBLIC_KEY, dsc.public_key.to_string());
199 if let Some(release) = &dsc.release {
200 attributes.insert(DSC_RELEASE, release.clone());
201 }
202 if let Some(environment) = &dsc.environment {
203 attributes.insert(DSC_ENVIRONMENT, environment.clone());
204 }
205 if let Some(transaction) = &dsc.transaction {
206 attributes.insert(DSC_TRANSACTION, transaction.clone());
207 }
208 if let Some(sample_rate) = dsc.sample_rate {
209 attributes.insert(DSC_SAMPLE_RATE, sample_rate);
210 }
211 if let Some(sampled) = dsc.sampled {
212 attributes.insert(DSC_SAMPLED, sampled);
213 }
214}
215
216pub fn normalize_attribute_names(attributes: &mut Annotated<Attributes>) {
225 normalize_attribute_names_inner(attributes, relay_conventions::attribute_info)
226}
227
228fn normalize_attribute_names_inner(
229 attributes: &mut Annotated<Attributes>,
230 attribute_info: fn(&str) -> Option<&'static AttributeInfo>,
231) {
232 let Some(attributes) = attributes.value_mut() else {
233 return;
234 };
235
236 let attribute_names: Vec<_> = attributes.0.keys().cloned().collect();
237
238 for name in attribute_names {
239 let Some(attribute_info) = attribute_info(&name) else {
240 continue;
241 };
242
243 match attribute_info.write_behavior {
244 WriteBehavior::CurrentName => continue,
245 WriteBehavior::NewName(new_name) => {
246 let Some(old_attribute) = attributes.0.get_mut(&name) else {
247 continue;
248 };
249
250 let mut meta = Meta::default();
251 meta.add_remark(Remark::new(RemarkType::Removed, "attribute.deprecated"));
253 let new_attribute = std::mem::replace(old_attribute, Annotated(None, meta));
254
255 if !attributes.contains_key(new_name) {
256 attributes.0.insert(new_name.to_owned(), new_attribute);
257 }
258 }
259 WriteBehavior::BothNames(new_name) => {
260 if !attributes.contains_key(new_name)
261 && let Some(current_attribute) = attributes.0.get(&name).cloned()
262 {
263 attributes.0.insert(new_name.to_owned(), current_attribute);
264 }
265 }
266 }
267 }
268}
269
270pub fn normalize_attribute_values(attributes: &mut Annotated<Attributes>) {
279 normalize_db_attributes(attributes);
280}
281
282pub fn normalize_db_attributes(annotated_attributes: &mut Annotated<Attributes>) {
291 let Some(attributes) = annotated_attributes.value() else {
292 return;
293 };
294
295 if attributes.get_value(NORMALIZED_DB_QUERY).is_some() {
297 return;
298 }
299
300 let (op, sub_op) = attributes
301 .get_value(OP)
302 .and_then(|v| v.as_str())
303 .map(|op| op.split_once('.').unwrap_or((op, "")))
304 .unwrap_or_default();
305
306 let raw_query = attributes
307 .get_value(DB_QUERY_TEXT)
308 .or_else(|| {
309 if op == "db" {
310 attributes.get_value(DESCRIPTION)
311 } else {
312 None
313 }
314 })
315 .and_then(|v| v.as_str());
316
317 let db_system = attributes
318 .get_value(DB_SYSTEM_NAME)
319 .and_then(|v| v.as_str());
320
321 let db_operation = attributes
322 .get_value(DB_OPERATION_NAME)
323 .and_then(|v| v.as_str());
324
325 let collection_name = attributes
326 .get_value(DB_COLLECTION_NAME)
327 .and_then(|v| v.as_str());
328
329 let span_origin = attributes.get_value(ORIGIN).and_then(|v| v.as_str());
330
331 let (normalized_db_query, parsed_sql) = if let Some(raw_query) = raw_query {
332 scrub_db_query(
333 raw_query,
334 sub_op,
335 db_system,
336 db_operation,
337 collection_name,
338 span_origin,
339 )
340 } else {
341 (None, None)
342 };
343
344 let db_operation = if db_operation.is_none() {
345 if sub_op == "redis" || db_system == Some("redis") {
346 if let Some(query) = normalized_db_query.as_ref() {
348 let command = query.replace(" *", "");
349 if command.is_empty() {
350 None
351 } else {
352 Some(command)
353 }
354 } else {
355 None
356 }
357 } else if let Some(raw_query) = raw_query {
358 sql_action_from_query(raw_query).map(|a| a.to_uppercase())
360 } else {
361 None
362 }
363 } else {
364 db_operation.map(|db_operation| db_operation.to_uppercase())
365 };
366
367 let db_collection_name: Option<String> = if let Some(name) = collection_name {
368 if db_system == Some("mongodb") {
369 match TABLE_NAME_REGEX.replace_all(name, "{%s}") {
370 Cow::Owned(s) => Some(s),
371 Cow::Borrowed(_) => Some(name.to_owned()),
372 }
373 } else {
374 Some(name.to_owned())
375 }
376 } else if span_origin == Some("auto.db.supabase") {
377 normalized_db_query
378 .as_ref()
379 .and_then(|query| query.strip_prefix("from("))
380 .and_then(|s| s.strip_suffix(")"))
381 .map(String::from)
382 } else if let Some(raw_query) = raw_query {
383 sql_tables_from_query(raw_query, &parsed_sql)
384 } else {
385 None
386 };
387
388 if let Some(attributes) = annotated_attributes.value_mut() {
389 if let Some(normalized_db_query) = normalized_db_query {
390 attributes.insert(NORMALIZED_DB_QUERY, normalized_db_query);
391 }
392 if let Some(db_operation_name) = db_operation {
393 attributes.insert(DB_OPERATION_NAME, db_operation_name)
394 }
395 if let Some(db_collection_name) = db_collection_name {
396 attributes.insert(DB_COLLECTION_NAME, db_collection_name);
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use relay_protocol::SerializableAnnotated;
404
405 use super::*;
406
407 #[test]
408 fn test_normalize_received_none() {
409 let mut attributes = Default::default();
410
411 normalize_received(
412 &mut attributes,
413 DateTime::from_timestamp_nanos(1_234_201_337),
414 );
415
416 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
417 {
418 "sentry.observed_timestamp_nanos": {
419 "type": "string",
420 "value": "1234201337"
421 }
422 }
423 "#);
424 }
425
426 #[test]
427 fn test_normalize_received_existing() {
428 let mut attributes = Annotated::from_json(
429 r#"{
430 "sentry.observed_timestamp_nanos": {
431 "type": "string",
432 "value": "111222333"
433 }
434 }"#,
435 )
436 .unwrap();
437
438 normalize_received(
439 &mut attributes,
440 DateTime::from_timestamp_nanos(1_234_201_337),
441 );
442
443 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
444 {
445 "sentry.observed_timestamp_nanos": {
446 "type": "string",
447 "value": "111222333"
448 }
449 }
450 "###);
451 }
452
453 #[test]
454 fn test_process_attribute_types() {
455 let json = r#"{
456 "valid_bool": {
457 "type": "boolean",
458 "value": true
459 },
460 "valid_int_i64": {
461 "type": "integer",
462 "value": -42
463 },
464 "valid_int_u64": {
465 "type": "integer",
466 "value": 42
467 },
468 "valid_int_from_string": {
469 "type": "integer",
470 "value": "42"
471 },
472 "valid_double": {
473 "type": "double",
474 "value": 42.5
475 },
476 "double_with_i64": {
477 "type": "double",
478 "value": -42
479 },
480 "valid_double_with_u64": {
481 "type": "double",
482 "value": 42
483 },
484 "valid_string": {
485 "type": "string",
486 "value": "test"
487 },
488 "valid_string_with_other": {
489 "type": "string",
490 "value": "test",
491 "some_other_field": "some_other_value"
492 },
493 "unknown_type": {
494 "type": "custom",
495 "value": "test"
496 },
497 "invalid_int_from_invalid_string": {
498 "type": "integer",
499 "value": "abc"
500 },
501 "missing_type": {
502 "value": "value with missing type"
503 },
504 "missing_value": {
505 "type": "string"
506 }
507 }"#;
508
509 let mut attributes = Annotated::<Attributes>::from_json(json).unwrap();
510 normalize_attribute_types(&mut attributes);
511
512 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
513 {
514 "double_with_i64": {
515 "type": "double",
516 "value": -42
517 },
518 "invalid_int_from_invalid_string": null,
519 "missing_type": null,
520 "missing_value": null,
521 "unknown_type": null,
522 "valid_bool": {
523 "type": "boolean",
524 "value": true
525 },
526 "valid_double": {
527 "type": "double",
528 "value": 42.5
529 },
530 "valid_double_with_u64": {
531 "type": "double",
532 "value": 42
533 },
534 "valid_int_from_string": null,
535 "valid_int_i64": {
536 "type": "integer",
537 "value": -42
538 },
539 "valid_int_u64": {
540 "type": "integer",
541 "value": 42
542 },
543 "valid_string": {
544 "type": "string",
545 "value": "test"
546 },
547 "valid_string_with_other": {
548 "type": "string",
549 "value": "test",
550 "some_other_field": "some_other_value"
551 },
552 "_meta": {
553 "invalid_int_from_invalid_string": {
554 "": {
555 "err": [
556 "invalid_data"
557 ],
558 "val": {
559 "type": "integer",
560 "value": "abc"
561 }
562 }
563 },
564 "missing_type": {
565 "": {
566 "err": [
567 "missing_attribute"
568 ],
569 "val": {
570 "type": null,
571 "value": "value with missing type"
572 }
573 }
574 },
575 "missing_value": {
576 "": {
577 "err": [
578 "missing_attribute"
579 ],
580 "val": {
581 "type": "string",
582 "value": null
583 }
584 }
585 },
586 "unknown_type": {
587 "": {
588 "err": [
589 "invalid_data"
590 ],
591 "val": {
592 "type": "custom",
593 "value": "test"
594 }
595 }
596 },
597 "valid_int_from_string": {
598 "": {
599 "err": [
600 "invalid_data"
601 ],
602 "val": {
603 "type": "integer",
604 "value": "42"
605 }
606 }
607 }
608 }
609 }
610 "###);
611 }
612
613 #[test]
614 fn test_normalize_user_agent_none() {
615 let mut attributes = Default::default();
616 normalize_user_agent(
617 &mut attributes,
618 Some(
619 "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",
620 ),
621 ClientHints::default(),
622 );
623
624 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
625 {
626 "sentry.browser.name": {
627 "type": "string",
628 "value": "Chrome"
629 },
630 "sentry.browser.version": {
631 "type": "string",
632 "value": "131.0.0"
633 }
634 }
635 "#);
636 }
637
638 #[test]
639 fn test_normalize_user_agent_existing() {
640 let mut attributes = Annotated::from_json(
641 r#"{
642 "sentry.browser.name": {
643 "type": "string",
644 "value": "Very Special"
645 },
646 "sentry.browser.version": {
647 "type": "string",
648 "value": "13.3.7"
649 }
650 }"#,
651 )
652 .unwrap();
653
654 normalize_user_agent(
655 &mut attributes,
656 Some(
657 "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",
658 ),
659 ClientHints::default(),
660 );
661
662 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
663 {
664 "sentry.browser.name": {
665 "type": "string",
666 "value": "Very Special"
667 },
668 "sentry.browser.version": {
669 "type": "string",
670 "value": "13.3.7"
671 }
672 }
673 "#,
674 );
675 }
676
677 #[test]
678 fn test_normalize_user_geo_none() {
679 let mut attributes = Default::default();
680
681 normalize_user_geo(&mut attributes, || {
682 Some(Geo {
683 country_code: "XY".to_owned().into(),
684 city: "Foo Hausen".to_owned().into(),
685 subdivision: Annotated::empty(),
686 region: "Illu".to_owned().into(),
687 other: Default::default(),
688 })
689 });
690
691 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
692 {
693 "user.geo.city": {
694 "type": "string",
695 "value": "Foo Hausen"
696 },
697 "user.geo.country_code": {
698 "type": "string",
699 "value": "XY"
700 },
701 "user.geo.region": {
702 "type": "string",
703 "value": "Illu"
704 }
705 }
706 "#);
707 }
708
709 #[test]
710 fn test_normalize_user_geo_existing() {
711 let mut attributes = Annotated::from_json(
712 r#"{
713 "user.geo.city": {
714 "type": "string",
715 "value": "Foo Hausen"
716 }
717 }"#,
718 )
719 .unwrap();
720
721 normalize_user_geo(&mut attributes, || unreachable!());
722
723 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
724 {
725 "user.geo.city": {
726 "type": "string",
727 "value": "Foo Hausen"
728 }
729 }
730 "#,
731 );
732 }
733
734 #[test]
735 fn test_normalize_attributes() {
736 fn mock_attribute_info(name: &str) -> Option<&'static AttributeInfo> {
737 use relay_conventions::Pii;
738
739 match name {
740 "replace.empty" => Some(&AttributeInfo {
741 write_behavior: WriteBehavior::NewName("replaced"),
742 pii: Pii::Maybe,
743 aliases: &["replaced"],
744 }),
745 "replace.existing" => Some(&AttributeInfo {
746 write_behavior: WriteBehavior::NewName("not.replaced"),
747 pii: Pii::Maybe,
748 aliases: &["not.replaced"],
749 }),
750 "backfill.empty" => Some(&AttributeInfo {
751 write_behavior: WriteBehavior::BothNames("backfilled"),
752 pii: Pii::Maybe,
753 aliases: &["backfilled"],
754 }),
755 "backfill.existing" => Some(&AttributeInfo {
756 write_behavior: WriteBehavior::BothNames("not.backfilled"),
757 pii: Pii::Maybe,
758 aliases: &["not.backfilled"],
759 }),
760 _ => None,
761 }
762 }
763
764 let mut attributes = Annotated::new(Attributes::from([
765 (
766 "replace.empty".to_owned(),
767 Annotated::new("Should be moved".to_owned().into()),
768 ),
769 (
770 "replace.existing".to_owned(),
771 Annotated::new("Should be removed".to_owned().into()),
772 ),
773 (
774 "not.replaced".to_owned(),
775 Annotated::new("Should be left alone".to_owned().into()),
776 ),
777 (
778 "backfill.empty".to_owned(),
779 Annotated::new("Should be copied".to_owned().into()),
780 ),
781 (
782 "backfill.existing".to_owned(),
783 Annotated::new("Should be left alone".to_owned().into()),
784 ),
785 (
786 "not.backfilled".to_owned(),
787 Annotated::new("Should be left alone".to_owned().into()),
788 ),
789 ]));
790
791 normalize_attribute_names_inner(&mut attributes, mock_attribute_info);
792
793 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
794 {
795 "backfill.empty": {
796 "type": "string",
797 "value": "Should be copied"
798 },
799 "backfill.existing": {
800 "type": "string",
801 "value": "Should be left alone"
802 },
803 "backfilled": {
804 "type": "string",
805 "value": "Should be copied"
806 },
807 "not.backfilled": {
808 "type": "string",
809 "value": "Should be left alone"
810 },
811 "not.replaced": {
812 "type": "string",
813 "value": "Should be left alone"
814 },
815 "replace.empty": null,
816 "replace.existing": null,
817 "replaced": {
818 "type": "string",
819 "value": "Should be moved"
820 },
821 "_meta": {
822 "replace.empty": {
823 "": {
824 "rem": [
825 [
826 "attribute.deprecated",
827 "x"
828 ]
829 ]
830 }
831 },
832 "replace.existing": {
833 "": {
834 "rem": [
835 [
836 "attribute.deprecated",
837 "x"
838 ]
839 ]
840 }
841 }
842 }
843 }
844 "###);
845 }
846
847 #[test]
848 fn test_normalize_span_infers_op() {
849 let mut attributes = Annotated::<Attributes>::from_json(
850 r#"{
851 "db.system.name": {
852 "type": "string",
853 "value": "mysql"
854 },
855 "db.operation.name": {
856 "type": "string",
857 "value": "query"
858 }
859 }
860 "#,
861 )
862 .unwrap();
863
864 normalize_sentry_op(&mut attributes);
865
866 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
867 {
868 "db.operation.name": {
869 "type": "string",
870 "value": "query"
871 },
872 "db.system.name": {
873 "type": "string",
874 "value": "mysql"
875 },
876 "sentry.op": {
877 "type": "string",
878 "value": "db"
879 }
880 }
881 "#);
882 }
883
884 #[test]
885 fn test_normalize_attribute_values_mysql_db_query_attributes() {
886 let mut attributes = Annotated::<Attributes>::from_json(
887 r#"
888 {
889 "sentry.op": {
890 "type": "string",
891 "value": "db.query"
892 },
893 "sentry.origin": {
894 "type": "string",
895 "value": "auto.otlp.spans"
896 },
897 "db.system.name": {
898 "type": "string",
899 "value": "mysql"
900 },
901 "db.query.text": {
902 "type": "string",
903 "value": "SELECT \"not an identifier\""
904 }
905 }
906 "#,
907 )
908 .unwrap();
909
910 normalize_db_attributes(&mut attributes);
911
912 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
913 {
914 "db.operation.name": {
915 "type": "string",
916 "value": "SELECT"
917 },
918 "db.query.text": {
919 "type": "string",
920 "value": "SELECT \"not an identifier\""
921 },
922 "db.system.name": {
923 "type": "string",
924 "value": "mysql"
925 },
926 "sentry.normalized_db_query": {
927 "type": "string",
928 "value": "SELECT %s"
929 },
930 "sentry.op": {
931 "type": "string",
932 "value": "db.query"
933 },
934 "sentry.origin": {
935 "type": "string",
936 "value": "auto.otlp.spans"
937 }
938 }
939 "#);
940 }
941
942 #[test]
943 fn test_normalize_mongodb_db_query_attributes() {
944 let mut attributes = Annotated::<Attributes>::from_json(
945 r#"
946 {
947 "sentry.op": {
948 "type": "string",
949 "value": "db"
950 },
951 "db.system.name": {
952 "type": "string",
953 "value": "mongodb"
954 },
955 "db.query.text": {
956 "type": "string",
957 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
958 },
959 "db.operation.name": {
960 "type": "string",
961 "value": "find"
962 },
963 "db.collection.name": {
964 "type": "string",
965 "value": "documents"
966 }
967 }
968 "#,
969 )
970 .unwrap();
971
972 normalize_db_attributes(&mut attributes);
973
974 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
975 {
976 "db.collection.name": {
977 "type": "string",
978 "value": "documents"
979 },
980 "db.operation.name": {
981 "type": "string",
982 "value": "FIND"
983 },
984 "db.query.text": {
985 "type": "string",
986 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
987 },
988 "db.system.name": {
989 "type": "string",
990 "value": "mongodb"
991 },
992 "sentry.normalized_db_query": {
993 "type": "string",
994 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
995 },
996 "sentry.op": {
997 "type": "string",
998 "value": "db"
999 }
1000 }
1001 "#);
1002 }
1003
1004 #[test]
1005 fn test_normalize_db_attributes_does_not_update_attributes_if_already_normalized() {
1006 let mut attributes = Annotated::<Attributes>::from_json(
1007 r#"
1008 {
1009 "db.collection.name": {
1010 "type": "string",
1011 "value": "documents"
1012 },
1013 "db.operation.name": {
1014 "type": "string",
1015 "value": "FIND"
1016 },
1017 "db.query.text": {
1018 "type": "string",
1019 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1020 },
1021 "db.system.name": {
1022 "type": "string",
1023 "value": "mongodb"
1024 },
1025 "sentry.normalized_db_query": {
1026 "type": "string",
1027 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1028 },
1029 "sentry.op": {
1030 "type": "string",
1031 "value": "db"
1032 }
1033 }
1034 "#,
1035 )
1036 .unwrap();
1037
1038 normalize_db_attributes(&mut attributes);
1039
1040 insta::assert_json_snapshot!(
1041 SerializableAnnotated(&attributes), @r#"
1042 {
1043 "db.collection.name": {
1044 "type": "string",
1045 "value": "documents"
1046 },
1047 "db.operation.name": {
1048 "type": "string",
1049 "value": "FIND"
1050 },
1051 "db.query.text": {
1052 "type": "string",
1053 "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1054 },
1055 "db.system.name": {
1056 "type": "string",
1057 "value": "mongodb"
1058 },
1059 "sentry.normalized_db_query": {
1060 "type": "string",
1061 "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1062 },
1063 "sentry.op": {
1064 "type": "string",
1065 "value": "db"
1066 }
1067 }
1068 "#
1069 );
1070 }
1071
1072 #[test]
1073 fn test_normalize_db_attributes_does_not_change_non_db_spans() {
1074 let mut attributes = Annotated::<Attributes>::from_json(
1075 r#"
1076 {
1077 "sentry.op": {
1078 "type": "string",
1079 "value": "http.client"
1080 },
1081 "sentry.origin": {
1082 "type": "string",
1083 "value": "auto.otlp.spans"
1084 },
1085 "http.request.method": {
1086 "type": "string",
1087 "value": "GET"
1088 }
1089 }
1090 "#,
1091 )
1092 .unwrap();
1093
1094 normalize_db_attributes(&mut attributes);
1095
1096 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1097 {
1098 "http.request.method": {
1099 "type": "string",
1100 "value": "GET"
1101 },
1102 "sentry.op": {
1103 "type": "string",
1104 "value": "http.client"
1105 },
1106 "sentry.origin": {
1107 "type": "string",
1108 "value": "auto.otlp.spans"
1109 }
1110 }
1111 "#);
1112 }
1113}