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