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