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, 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
31/// Infers the sentry.op attribute and inserts it into [`Attributes`] if not already set.
32pub 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
44/// Normalizes/validates all attribute types.
45///
46/// Removes and marks all attributes with an error for which the specified [`AttributeType`]
47/// does not match the value.
48pub 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            // Note: currently the mapping to Kafka requires that invalid or unknown combinations
76            // of types and values are removed from the mapping.
77            //
78            // Usually Relay would only modify the offending values, but for now, until there
79            // is better support in the pipeline here, we need to remove the entire attribute.
80            (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
99/// Returns `true` if the passed array is an array we currently support.
100///
101/// Currently all arrays must be homogeneous types.
102fn is_supported_array(arr: &[Annotated<Value>]) -> bool {
103    let mut iter = arr.iter();
104
105    let Some(first) = iter.next() else {
106        // Empty arrays are supported.
107        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                // We allow mixing different numeric types because they are all the same in JSON.
119                Some(Value::I64(_) | Value::U64(_) | Value::F64(_)),
120                Some(Value::I64(_) | Value::U64(_) | Value::F64(_)),
121            ) => prev,
122            // Everything else is unsupported.
123            //
124            // This includes nested arrays, nested objects and mixed arrays for now.
125            (Some(_), Some(_)) => return None,
126        };
127
128        Some(r)
129    });
130
131    let Some(item) = item else {
132        // Unsupported combination of types.
133        return false;
134    };
135
136    matches!(
137        item.value(),
138        // `None` -> `[null, null]` is allowed, as the `Annotated` may carry information.
139        // `Some` -> must be a currently supported type.
140        None | Some(
141            Value::String(_) | Value::Bool(_) | Value::I64(_) | Value::U64(_) | Value::F64(_)
142        )
143    )
144}
145
146/// Adds the `received` time to the attributes.
147pub 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
158/// Normalizes the user agent/client information into [`Attributes`].
159///
160/// Does not modify the attributes if there is already browser information present,
161/// to preserve original values.
162pub 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    // Prefer the stored/explicitly sent user agent over the user agent from the client/transport.
174    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
190/// Normalizes the client address into [`Attributes`].
191///
192/// Infers the client ip from the client information which was provided to Relay, if the SDK
193/// indicates the client ip should be inferred by setting it to `{{auto}}`.
194///
195/// This requires cooperation from SDKs as inferring a client ip only works in non-server
196/// environments, where the user/client device is also the device sending the item.
197pub 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
212/// Normalizes the user's geographical information into [`Attributes`].
213///
214/// Does not modify the attributes if there is already user geo information present,
215/// to preserve original values.
216pub 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
244/// Normalizes the [DSC](DynamicSamplingContext) into [`Attributes`].
245pub 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    // Check if DSC attributes are already set, the trace id is always required and must always be set.
251    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
274/// Normalizes deprecated attributes according to `sentry-conventions`.
275///
276/// Attributes with a status of `"normalize"` will be moved to their replacement name.
277/// If there is already a value present under the replacement name, it will be left alone,
278/// but the deprecated attribute is removed anyway.
279///
280/// Attributes with a status of `"backfill"` will be copied to their replacement name if the
281/// replacement name is not present. In any case, the original name is left alone.
282pub 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                // TODO: Possibly add a new RemarkType for "renamed/moved"
310                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
328/// Normalizes the values of a set of attributes if present in the span.
329///
330/// Each span type has a set of important attributes containing the main relevant information displayed
331/// in the product-end. For instance, for DB spans, these attributes are `db.query.text`, `db.operation.name`,
332/// `db.collection.name`. Previously, V1 spans always held these important values in the `description` field,
333/// however, V2 spans now store these values in their respective attributes based on sentry conventions.
334/// This function ports over the SpanV1 normalization logic that was previously in `scrub_span_description`
335/// by creating a set of functions to handle each group of attributes separately.
336pub 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
344/// Normalizes the following db attributes: `db.query.text`, `db.operation.name`, `db.collection.name`
345/// based on related attributes within DB spans.
346///
347/// This function reads the raw db query from `db.query.text`, scrubs it if possible, and writes
348/// the normalized query to the `sentry.normalized_db_query` attribute. After normalizing the query,
349/// the db operation and collection name are updated if needed.
350///
351/// Note: This function assumes that the sentry.op has already been inferred and set in the attributes.
352fn normalize_db_attributes(annotated_attributes: &mut Annotated<Attributes>) {
353    let Some(attributes) = annotated_attributes.value() else {
354        return;
355    };
356
357    // Skip normalization if the normalized db query attribute is already set.
358    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            // This only works as long as redis span descriptions contain the command + " *"
409            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            // For other database operations, try to get the operation from data
421            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
467/// Normalizes the following http attributes: `http.request.method` and `server.address`.
468///
469/// The normalization process first scrubs the url and extracts the server address from the url.
470/// It also sets 'url.full' to the raw url if it is not already set and can be retrieved from the server address.
471fn 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    // Skip normalization if not an http span.
480    // This is equivalent to conditionally scrubbing by span category in the V1 pipeline.
481    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    // If the span op is "http.client" and the method and url are present,
510    // extract a normalized domain to be stored in the "server.address" attribute.
511    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
543/// Double writes sentry conventions attributes into legacy attributes.
544///
545/// This achieves backwards compatibility as it allows products to continue using legacy attributes
546/// while we accumulate spans that conform to sentry conventions.
547///
548/// This function is called after attribute value normalization (`normalize_attribute_values`) as it
549/// clones normalized attributes into legacy attributes.
550pub fn write_legacy_attributes(attributes: &mut Annotated<Attributes>) {
551    let Some(attributes) = attributes.value_mut() else {
552        return;
553    };
554
555    // Map of new sentry conventions attributes to legacy SpanV1 attributes
556    let current_to_legacy_attributes = [
557        // DB attributes
558        (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        // HTTP attributes
563        (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}