Skip to main content

relay_event_normalization/eap/
mod.rs

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