Skip to main content

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