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