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