relay_event_normalization/eap/
mod.rs

1//! Event normalization and processing for attribute (EAP) based payloads.
2//!
3//! A central place for all modifications/normalizations for attributes.
4
5use 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
26/// Infers the sentry.op attribute and inserts it into [`Attributes`] if not already set.
27pub 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
39/// Normalizes/validates all attribute types.
40///
41/// Removes and marks all attributes with an error for which the specified [`AttributeType`]
42/// does not match the value.
43pub 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            // Note: currently the mapping to Kafka requires that invalid or unknown combinations
65            // of types and values are removed from the mapping.
66            //
67            // Usually Relay would only modify the offending values, but for now, until there
68            // is better support in the pipeline here, we need to remove the entire attribute.
69            (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
88/// Adds the `received` time to the attributes.
89pub 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
100/// Normalizes the user agent/client information into [`Attributes`].
101///
102/// Does not modify the attributes if there is already browser information present,
103/// to preserve original values.
104pub 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    // Prefer the stored/explicitly sent user agent over the user agent from the client/transport.
116    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
132/// Normalizes the client address into [`Attributes`].
133///
134/// Infers the client ip from the client information which was provided to Relay, if the SDK
135/// indicates the client ip should be inferred by setting it to `{{auto}}`.
136///
137/// This requires cooperation from SDKs as inferring a client ip only works in non-server
138/// environments, where the user/client device is also the device sending the item.
139pub 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
154/// Normalizes the user's geographical information into [`Attributes`].
155///
156/// Does not modify the attributes if there is already user geo information present,
157/// to preserve original values.
158pub 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
186/// Normalizes the [DSC](DynamicSamplingContext) into [`Attributes`].
187pub 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    // Check if DSC attributes are already set, the trace id is always required and must always be set.
193    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
216/// Normalizes deprecated attributes according to `sentry-conventions`.
217///
218/// Attributes with a status of `"normalize"` will be moved to their replacement name.
219/// If there is already a value present under the replacement name, it will be left alone,
220/// but the deprecated attribute is removed anyway.
221///
222/// Attributes with a status of `"backfill"` will be copied to their replacement name if the
223/// replacement name is not present. In any case, the original name is left alone.
224pub 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                // TODO: Possibly add a new RemarkType for "renamed/moved"
252                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
270/// Normalizes the values of a set of attributes if present in the span.
271///
272/// Each span type has a set of important attributes containing the main relevant information displayed
273/// in the product-end. For instance, for DB spans, these attributes are `db.query.text`, `db.operation.name`,
274/// `db.collection.name`. Previously, V1 spans always held these important values in the `description` field,
275/// however, V2 spans now store these values in their respective attributes based on sentry conventions.
276/// This function ports over the SpanV1 normalization logic that was previously in `scrub_span_description`
277/// by creating a set of functions to handle each group of attributes separately.
278pub fn normalize_attribute_values(attributes: &mut Annotated<Attributes>) {
279    normalize_db_attributes(attributes);
280}
281
282/// Normalizes the following db attributes: `db.query.text`, `db.operation.name`, `db.collection.name`
283/// based on related attributes within DB spans.
284///
285/// This function reads the raw db query from `db.query.text`, scrubs it if possible, and writes
286/// the normalized query to the `sentry.normalized_db_query` attribute. After normalizing the query,
287/// the db operation and collection name are updated if needed.
288///
289/// Note: This function assumes that the sentry.op has already been inferred and set in the attributes.
290pub fn normalize_db_attributes(annotated_attributes: &mut Annotated<Attributes>) {
291    let Some(attributes) = annotated_attributes.value() else {
292        return;
293    };
294
295    // Skip normalization if the normalized db query attribute is already set.
296    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            // This only works as long as redis span descriptions contain the command + " *"
347            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            // For other database operations, try to get the operation from data
359            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}