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