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