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/// This is only relevant for spans as other eap types re not sampled.
545pub fn normalize_client_sample_rate(attributes: &mut Annotated<Attributes>) {
546    let Some(attributes) = attributes.value_mut() else {
547        return;
548    };
549
550    // This is fine if normalizations like this stay one-offs. If at some point we end up with more
551    // of these structural validations or normalizations based on attributes, they should be
552    // outsourced to conventions and enforced with a dedicated processor.
553    fn normalize_sample_rate(sr: &Annotated<Attribute>) -> Option<Annotated<Attribute>> {
554        match sr.value()?.value.value.value()?.as_f64() {
555            Some(v) if v > 0.0 && v <= 1.0 => None,
556            // This is an invalid sample rate, either by type or value.
557            _ => Some(Annotated::from_error(
558                Error::expected("sample rate > 0.0, <= 1.0"),
559                None,
560            )),
561        }
562    }
563
564    if let Some(sr) = attributes.0.get_mut(SENTRY__CLIENT_SAMPLE_RATE)
565        && let Some(new_sr) = normalize_sample_rate(sr)
566    {
567        *sr = new_sr;
568    }
569}
570
571/// Normalizes deprecated attributes according to `sentry-conventions`.
572///
573/// Attributes with a status of `"normalize"` will be moved to their replacement name.
574/// If there is already a value present under the replacement name, it will be left alone,
575/// but the deprecated attribute is removed anyway.
576///
577/// Attributes with a status of `"backfill"` will be copied to their replacement name if the
578/// replacement name is not present. In any case, the original name is left alone.
579pub fn normalize_attribute_names(attributes: &mut Annotated<impl AttributesLike>) {
580    let Some(attributes) = attributes.value_mut() else {
581        return;
582    };
583
584    normalize_attribute_names_inner(
585        attributes.as_object_mut(),
586        relay_conventions::attribute_info_with_fragment,
587    )
588}
589
590type AttributeInfoFn = fn(&str) -> Option<(&'static AttributeInfo, Option<&str>)>;
591
592fn normalize_attribute_names_inner<T>(attributes: &mut Object<T>, attribute_info: AttributeInfoFn)
593where
594    T: Clone,
595{
596    let attribute_names: Vec<_> = attributes.keys().cloned().collect();
597
598    for name in attribute_names {
599        let Some((attribute_info, fragment)) = attribute_info(&name) else {
600            continue;
601        };
602
603        match attribute_info.write_behavior {
604            WriteBehavior::CurrentName => continue,
605            WriteBehavior::NewName(new_name) => {
606                let Some(old_attribute) = attributes.get_mut(&name) else {
607                    continue;
608                };
609
610                let Some(new_name) = resolve_attribute_name(new_name, fragment) else {
611                    relay_log::error!(
612                        attribute = name,
613                        ?fragment,
614                        "Attribute placeholder mismatch"
615                    );
616                    continue;
617                };
618
619                let mut meta = Meta::default();
620                // TODO: Possibly add a new RemarkType for "renamed/moved"
621                meta.add_remark(Remark::new(RemarkType::Removed, "attribute.deprecated"));
622                let new_attribute = std::mem::replace(old_attribute, Annotated(None, meta));
623
624                if !attributes.contains_key(&*new_name) {
625                    attributes.insert(new_name.into_owned(), new_attribute);
626                }
627            }
628            WriteBehavior::BothNames(new_name) => {
629                let Some(new_name) = resolve_attribute_name(new_name, fragment) else {
630                    relay_log::error!(
631                        attribute = name,
632                        ?fragment,
633                        "Attribute placeholder mismatch"
634                    );
635                    continue;
636                };
637
638                if !attributes.contains_key(&*new_name)
639                    && let Some(current_attribute) = attributes.get(&name).cloned()
640                {
641                    attributes.insert(new_name.into_owned(), current_attribute);
642                }
643            }
644        }
645    }
646}
647
648/// Resolves the name of a replacement attribute for rewriting.
649///
650/// There are two cases to consider:
651/// - `name` is `Static` and `fragment` is `None`: This means that both
652///   the source and target attribute don't have a placeholder.
653/// - `name` is `Dynamic` and `fragment` is `Some`: This means that both the
654///   source and target attribute do have a placeholder.
655fn resolve_attribute_name(
656    name: ReplacementName,
657    fragment: Option<&str>,
658) -> Option<Cow<'static, str>> {
659    match (name, fragment) {
660        // Neither the original nor the replacement attribute contains a placeholder.
661        // Simply use the replacement attribute's static name.
662        (ReplacementName::Static(name), None) => Some(Cow::Borrowed(name)),
663        // Both the original and replacement attribute contain a placeholder.
664        // Use the replacement's interpolation function and the matched fragment
665        // to obtain the new name.
666        (ReplacementName::Dynamic(name_fn), Some(fragment)) => Some(Cow::Owned(name_fn(fragment))),
667        // The other cases would mean that either the original attribute contains a placeholder
668        // and the replacement doesn't, or vice versa. This is ruled out by a compile-time check
669        // in `relay-conventions`.
670        _ => None,
671    }
672}
673
674/// Normalizes the values of a set of attributes if present in the span.
675///
676/// Each span type has a set of important attributes containing the main relevant information displayed
677/// in the product-end. For instance, for DB spans, these attributes are `db.query.text`, `db.operation.name`,
678/// `db.collection.name`. Previously, V1 spans always held these important values in the `description` field,
679/// however, V2 spans now store these values in their respective attributes based on sentry conventions.
680/// This function ports over the SpanV1 normalization logic that was previously in `scrub_span_description`
681/// by creating a set of functions to handle each group of attributes separately.
682pub fn normalize_attribute_values(
683    attributes: &mut Annotated<Attributes>,
684    http_span_allowed_hosts: &[String],
685) {
686    normalize_db_attributes(attributes);
687    normalize_http_attributes(attributes, http_span_allowed_hosts);
688    normalize_mobile_attributes(attributes);
689}
690
691/// Normalizes the following db attributes: `db.query.text`, `db.operation.name`, `db.collection.name`
692/// based on related attributes within DB spans.
693///
694/// This function reads the raw db query from `db.query.text`, scrubs it if possible, and writes
695/// the normalized query to the `sentry.normalized_db_query` attribute. After normalizing the query,
696/// the db operation and collection name are updated if needed.
697///
698/// Note: This function assumes that the sentry.op has already been inferred and set in the attributes.
699fn normalize_db_attributes(annotated_attributes: &mut Annotated<Attributes>) {
700    let Some(attributes) = annotated_attributes.value() else {
701        return;
702    };
703
704    // Skip normalization if the normalized db query attribute is already set.
705    if attributes.get_value(SENTRY__NORMALIZED_DB_QUERY).is_some() {
706        return;
707    }
708
709    let (op, sub_op) = attributes
710        .get_value(SENTRY__OP)
711        .and_then(|v| v.as_str())
712        .map(|op| op.split_once('.').unwrap_or((op, "")))
713        .unwrap_or_default();
714
715    let raw_query = attributes
716        .get_value(DB__QUERY__TEXT)
717        .or_else(|| {
718            if op == "db" {
719                attributes.get_value(SENTRY__DESCRIPTION)
720            } else {
721                None
722            }
723        })
724        .and_then(|v| v.as_str());
725
726    let db_system = attributes
727        .get_value(DB__SYSTEM__NAME)
728        .and_then(|v| v.as_str());
729
730    let db_operation = attributes
731        .get_value(DB__OPERATION__NAME)
732        .and_then(|v| v.as_str());
733
734    let collection_name = attributes
735        .get_value(DB__COLLECTION__NAME)
736        .and_then(|v| v.as_str());
737
738    let span_origin = attributes
739        .get_value(SENTRY__ORIGIN)
740        .and_then(|v| v.as_str());
741
742    let (normalized_db_query, parsed_sql) = if let Some(raw_query) = raw_query {
743        scrub_db_query(
744            raw_query,
745            sub_op,
746            db_system,
747            db_operation,
748            collection_name,
749            span_origin,
750        )
751    } else {
752        (None, None)
753    };
754
755    let db_operation = if db_operation.is_none() {
756        if sub_op == "redis" || db_system == Some("redis") {
757            // This only works as long as redis span descriptions contain the command + " *"
758            if let Some(query) = normalized_db_query.as_ref() {
759                let command = query.replace(" *", "");
760                if command.is_empty() {
761                    None
762                } else {
763                    Some(command)
764                }
765            } else {
766                None
767            }
768        } else if let Some(raw_query) = raw_query {
769            // For other database operations, try to get the operation from data
770            sql_action_from_query(raw_query).map(|a| a.to_uppercase())
771        } else {
772            None
773        }
774    } else {
775        db_operation.map(|db_operation| db_operation.to_uppercase())
776    };
777
778    let db_collection_name: Option<String> = if let Some(name) = collection_name {
779        if db_system == Some("mongodb") {
780            match TABLE_NAME_REGEX.replace_all(name, "{%s}") {
781                Cow::Owned(s) => Some(s),
782                Cow::Borrowed(_) => Some(name.to_owned()),
783            }
784        } else {
785            Some(name.to_owned())
786        }
787    } else if span_origin == Some("auto.db.supabase") {
788        normalized_db_query
789            .as_ref()
790            .and_then(|query| query.strip_prefix("from("))
791            .and_then(|s| s.strip_suffix(")"))
792            .map(String::from)
793    } else if let Some(raw_query) = raw_query {
794        sql_tables_from_query(raw_query, &parsed_sql)
795    } else {
796        None
797    };
798
799    if let Some(attributes) = annotated_attributes.value_mut() {
800        if let Some(normalized_db_query) = normalized_db_query {
801            let mut normalized_db_query_hash = format!("{:x}", md5::compute(&normalized_db_query));
802            normalized_db_query_hash.truncate(16);
803
804            attributes.insert(SENTRY__NORMALIZED_DB_QUERY, normalized_db_query);
805            attributes.insert(SENTRY__NORMALIZED_DB_QUERY__HASH, normalized_db_query_hash);
806        }
807        if let Some(db_operation_name) = db_operation {
808            attributes.insert(DB__OPERATION__NAME, db_operation_name)
809        }
810        if let Some(db_collection_name) = db_collection_name {
811            attributes.insert(DB__COLLECTION__NAME, db_collection_name);
812        }
813    }
814}
815
816/// Normalizes the following http attributes: `http.request.method` and `server.address`.
817///
818/// The normalization process first scrubs the url and extracts the server address from the url.
819/// It also sets 'url.full' to the raw url if it is not already set and can be retrieved from the server address.
820fn normalize_http_attributes(
821    annotated_attributes: &mut Annotated<Attributes>,
822    allowed_hosts: &[String],
823) {
824    let Some(attributes) = annotated_attributes.value() else {
825        return;
826    };
827
828    // Skip normalization if not an http span.
829    if attributes
830        .get_value(SENTRY__CATEGORY)
831        .is_none_or(|category| category.as_str().unwrap_or_default() != "http")
832    {
833        return;
834    }
835
836    let op = attributes.get_value(SENTRY__OP).and_then(|v| v.as_str());
837
838    let (description_method, description_url) = match attributes
839        .get_value(SENTRY__DESCRIPTION)
840        .and_then(|v| v.as_str())
841        .and_then(|description| description.split_once(' '))
842    {
843        Some((method, url)) => (Some(method), Some(url)),
844        _ => (None, None),
845    };
846
847    let method = attributes
848        .get_value(HTTP__REQUEST__METHOD)
849        .and_then(|v| v.as_str())
850        .or(description_method);
851
852    let server_address = attributes
853        .get_value(SERVER__ADDRESS)
854        .and_then(|v| v.as_str());
855
856    let url: Option<&str> = attributes
857        .get_value(URL__FULL)
858        .and_then(|v| v.as_str())
859        .or(description_url);
860    let url_scheme = attributes.get_value(URL__SCHEME).and_then(|v| v.as_str());
861
862    // If the span op is "http.client" and the method and url are present,
863    // extract a normalized domain to be stored in the "server.address" attribute.
864    let (normalized_server_address, raw_url) = if op == Some("http.client") {
865        let domain_from_scrubbed_http = method
866            .zip(url)
867            .and_then(|(method, url)| scrub_http(method, url, allowed_hosts))
868            .and_then(|scrubbed_http| domain_from_scrubbed_http(&scrubbed_http));
869
870        if let Some(domain) = domain_from_scrubbed_http {
871            (Some(domain), url.map(String::from))
872        } else {
873            domain_from_server_address(server_address, url_scheme)
874        }
875    } else {
876        (None, None)
877    };
878
879    let method = method.map(|m| m.to_uppercase());
880
881    if let Some(attributes) = annotated_attributes.value_mut() {
882        if let Some(method) = method {
883            attributes.insert(HTTP__REQUEST__METHOD, method);
884        }
885
886        if let Some(normalized_server_address) = normalized_server_address {
887            attributes.insert(SERVER__ADDRESS, normalized_server_address);
888        }
889
890        if let Some(raw_url) = raw_url {
891            attributes.insert_if_missing(URL__FULL, || raw_url);
892        }
893    }
894}
895
896/// Makes sure web vital spans are not identified with segments.
897///
898/// This was ported from the legacy pipeline for behavior parity.
899/// At some point in the future, web vital spans will become metrics,
900/// and this will become academic.
901pub fn normalize_web_vital_span_segment(span: &mut SpanV2) {
902    let Some(attributes) = span.attributes.value_mut() else {
903        return;
904    };
905
906    if let Some(op) = attributes.get_value(SENTRY__OP)
907        && let Some(op_name) = op.as_str()
908        && (op_name.starts_with("ui.interaction.") || op_name.starts_with("ui.webvital."))
909    {
910        span.is_segment = None.into();
911        span.parent_span_id = None.into();
912        attributes.remove(SENTRY__SEGMENT__ID);
913    }
914}
915
916/// Normalize the [`SENTRY__SEGMENT__NAME`] attribute (aka the transaction)
917/// by running [`normalize_transaction_name`] on it.
918///
919/// This exists for parity with the legacy standalone span pipeline.
920pub fn normalize_segment_name(
921    attributes: &mut Annotated<Attributes>,
922    tx_name_rules: &[TransactionNameRule],
923) {
924    let Some(attributes) = attributes.value_mut() else {
925        return;
926    };
927
928    let Some(attr_value) = attributes.get_annotated_value_mut(SENTRY__SEGMENT__NAME) else {
929        return;
930    };
931
932    let mut segment_name = match &attr_value.0 {
933        Some(Value::String(s)) => Annotated(Some(s.to_owned()), attr_value.1.clone()),
934        _ => return,
935    };
936
937    normalize_transaction_name(&mut segment_name, tx_name_rules);
938
939    *attr_value = segment_name.map_value(Value::String);
940}
941
942/// Double writes sentry conventions attributes into legacy attributes.
943///
944/// This achieves backwards compatibility as it allows products to continue using legacy attributes
945/// while we accumulate spans that conform to sentry conventions.
946///
947/// This function is called after attribute value normalization (`normalize_attribute_values`) as it
948/// clones normalized attributes into legacy attributes.
949pub fn write_legacy_attributes(attributes: &mut Annotated<Attributes>) {
950    let Some(attributes) = attributes.value_mut() else {
951        return;
952    };
953
954    // Map of new sentry conventions attributes to legacy SpanV1 attributes
955    let current_to_legacy_attributes = [
956        // DB attributes
957        (SENTRY__NORMALIZED_DB_QUERY, SENTRY__NORMALIZED_DESCRIPTION),
958        (DB__OPERATION__NAME, SENTRY__ACTION),
959        // HTTP attributes
960        (SERVER__ADDRESS, SENTRY__DOMAIN),
961        (HTTP__REQUEST__METHOD, SENTRY__ACTION),
962        (HTTP__RESPONSE__STATUS_CODE, SENTRY__STATUS_CODE),
963    ];
964
965    for (current_attribute, legacy_attribute) in current_to_legacy_attributes {
966        if attributes.contains_key(legacy_attribute) {
967            continue;
968        }
969
970        let Some(attr) = attributes.get_attribute(current_attribute) else {
971            continue;
972        };
973
974        attributes.insert(legacy_attribute, attr.value.clone());
975    }
976
977    if !attributes.contains_key(SENTRY__DOMAIN)
978        && let Some(db_domain) = attributes
979            .get_value(DB__COLLECTION__NAME)
980            .and_then(|value| value.as_str())
981            .map(|collection_name| collection_name.to_owned())
982    {
983        // sentry.domain must be wrapped in preceding and trailing commas, for old hacky reasons.
984        attributes.insert(
985            SENTRY__DOMAIN,
986            match (db_domain.starts_with(','), db_domain.ends_with(',')) {
987                (true, true) => db_domain,
988                (true, false) => format!("{db_domain},"),
989                (false, true) => format!(",{db_domain}"),
990                (false, false) => format!(",{db_domain},"),
991            },
992        );
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use std::time::Duration;
999
1000    use relay_base_schema::project::ProjectId;
1001    use relay_protocol::{Empty, SerializableAnnotated, assert_annotated_snapshot};
1002    use relay_sampling::DynamicSamplingContext;
1003
1004    use super::*;
1005
1006    fn mock_dsc(transaction: Option<&str>) -> DynamicSamplingContext {
1007        DynamicSamplingContext {
1008            trace_id: "67e5504410b1426f9247bb680e5fe0c8".parse().unwrap(),
1009            public_key: "12345678901234567890123456789012".parse().unwrap(),
1010            project_id: Some(ProjectId::new(42)),
1011            release: None,
1012            environment: None,
1013            transaction: transaction.map(str::to_owned),
1014            sample_rate: None,
1015            user: Default::default(),
1016            replay_id: None,
1017            sampled: None,
1018            other: Default::default(),
1019        }
1020    }
1021
1022    #[test]
1023    fn test_normalize_dsc_child_span_no_dsc() {
1024        let mut attributes = Annotated::empty();
1025        normalize_dsc(&mut attributes, &Annotated::new(false), None);
1026        assert!(attributes.value().is_none());
1027    }
1028
1029    #[test]
1030    fn test_normalize_dsc_child_span_no_transaction() {
1031        let mut attributes = Annotated::empty();
1032        let dsc = &mock_dsc(None);
1033        normalize_dsc(&mut attributes, &Annotated::new(false), Some(dsc));
1034        assert_annotated_snapshot!(attributes, @r#"
1035        {
1036          "sentry.dsc.project_id": {
1037            "type": "string",
1038            "value": "42"
1039          },
1040          "sentry.dsc.trace_id": {
1041            "type": "string",
1042            "value": "67e5504410b1426f9247bb680e5fe0c8"
1043          }
1044        }
1045        "#);
1046    }
1047
1048    #[test]
1049    fn test_normalize_dsc_child_span() {
1050        let mut attributes = Annotated::empty();
1051        let dsc = &mock_dsc(Some("/some/endpoint"));
1052        normalize_dsc(&mut attributes, &Annotated::new(false), Some(dsc));
1053        assert_annotated_snapshot!(attributes, @r#"
1054        {
1055          "sentry.dsc.project_id": {
1056            "type": "string",
1057            "value": "42"
1058          },
1059          "sentry.dsc.trace_id": {
1060            "type": "string",
1061            "value": "67e5504410b1426f9247bb680e5fe0c8"
1062          },
1063          "sentry.dsc.transaction": {
1064            "type": "string",
1065            "value": "/some/endpoint"
1066          }
1067        }
1068        "#);
1069    }
1070
1071    #[test]
1072    fn test_normalize_dsc_segment() {
1073        let mut attributes = Annotated::empty();
1074        let dsc = &mock_dsc(Some("/some/endpoint"));
1075        normalize_dsc(&mut attributes, &Annotated::new(true), Some(dsc));
1076        assert_annotated_snapshot!(attributes, @r#"
1077        {
1078          "sentry.dsc.project_id": {
1079            "type": "string",
1080            "value": "42"
1081          },
1082          "sentry.dsc.public_key": {
1083            "type": "string",
1084            "value": "12345678901234567890123456789012"
1085          },
1086          "sentry.dsc.trace_id": {
1087            "type": "string",
1088            "value": "67e5504410b1426f9247bb680e5fe0c8"
1089          },
1090          "sentry.dsc.transaction": {
1091            "type": "string",
1092            "value": "/some/endpoint"
1093          }
1094        }
1095        "#);
1096    }
1097
1098    #[test]
1099    fn test_normalize_trace_status_not_segment() {
1100        let mut attributes = Annotated::empty();
1101        normalize_trace_status(
1102            &mut attributes,
1103            &Annotated::new(false),
1104            &Annotated::new(SpanV2Status::Ok),
1105        );
1106        assert!(attributes.value().is_none());
1107    }
1108
1109    #[test]
1110    fn test_normalize_trace_status_already_set() {
1111        let mut attributes = Annotated::from_json(
1112            r#"{"sentry.trace.status": {"type": "string", "value": "internal_error"}}"#,
1113        )
1114        .unwrap();
1115        normalize_trace_status(
1116            &mut attributes,
1117            &Annotated::new(true),
1118            &Annotated::new(SpanV2Status::Error),
1119        );
1120        assert_eq!(
1121            attributes
1122                .value()
1123                .unwrap()
1124                .get_value("sentry.trace.status")
1125                .and_then(|v| v.as_str()),
1126            Some("internal_error"),
1127        );
1128    }
1129
1130    #[test]
1131    fn test_normalize_trace_status_from_sentry_status_attribute() {
1132        let mut attributes = Annotated::from_json(
1133            r#"{"sentry.status": {"type": "string", "value": "internal_error"}}"#,
1134        )
1135        .unwrap();
1136        normalize_trace_status(
1137            &mut attributes,
1138            &Annotated::new(true),
1139            &Annotated::new(SpanV2Status::Error),
1140        );
1141        assert_eq!(
1142            attributes
1143                .value()
1144                .unwrap()
1145                .get_value("sentry.trace.status")
1146                .and_then(|v| v.as_str()),
1147            Some("internal_error"),
1148        );
1149    }
1150
1151    #[test]
1152    fn test_normalize_trace_status_from_span_status() {
1153        let mut attributes = Annotated::empty();
1154        normalize_trace_status(
1155            &mut attributes,
1156            &Annotated::new(true),
1157            &Annotated::new(SpanV2Status::Error),
1158        );
1159        assert_eq!(
1160            attributes
1161                .value()
1162                .unwrap()
1163                .get_value("sentry.trace.status")
1164                .and_then(|v| v.as_str()),
1165            Some("error"),
1166        );
1167    }
1168
1169    #[test]
1170    fn test_normalize_trace_status_no_status() {
1171        let mut attributes = Annotated::empty();
1172        normalize_trace_status(&mut attributes, &Annotated::new(true), &Annotated::empty());
1173        assert!(
1174            attributes
1175                .value()
1176                .unwrap()
1177                .get_value("sentry.trace.status")
1178                .is_none(),
1179        );
1180    }
1181
1182    #[test]
1183    fn test_normalize_received_none() {
1184        let mut attributes = Default::default();
1185
1186        normalize_received(
1187            &mut attributes,
1188            DateTime::from_timestamp_nanos(1_234_201_337),
1189        );
1190
1191        assert_annotated_snapshot!(attributes, @r#"
1192        {
1193          "sentry.observed_timestamp_nanos": {
1194            "type": "string",
1195            "value": "1234201337"
1196          }
1197        }
1198        "#);
1199    }
1200
1201    #[test]
1202    fn test_normalize_received_existing() {
1203        let mut attributes = Annotated::from_json(
1204            r#"{
1205          "sentry.observed_timestamp_nanos": {
1206            "type": "string",
1207            "value": "111222333"
1208          }
1209        }"#,
1210        )
1211        .unwrap();
1212
1213        normalize_received(
1214            &mut attributes,
1215            DateTime::from_timestamp_nanos(1_234_201_337),
1216        );
1217
1218        assert_annotated_snapshot!(attributes, @r###"
1219        {
1220          "sentry.observed_timestamp_nanos": {
1221            "type": "string",
1222            "value": "111222333"
1223          }
1224        }
1225        "###);
1226    }
1227
1228    #[test]
1229    fn test_process_attribute_types() {
1230        let json = r#"{
1231            "valid_bool": {
1232                "type": "boolean",
1233                "value": true
1234            },
1235            "valid_int_i64": {
1236                "type": "integer",
1237                "value": -42
1238            },
1239            "valid_int_u64": {
1240                "type": "integer",
1241                "value": 42
1242            },
1243            "valid_int_from_string": {
1244                "type": "integer",
1245                "value": "42"
1246            },
1247            "valid_double": {
1248                "type": "double",
1249                "value": 42.5
1250            },
1251            "double_with_i64": {
1252                "type": "double",
1253                "value": -42
1254            },
1255            "valid_double_with_u64": {
1256                "type": "double",
1257                "value": 42
1258            },
1259            "valid_string": {
1260                "type": "string",
1261                "value": "test"
1262            },
1263            "valid_string_with_other": {
1264                "type": "string",
1265                "value": "test",
1266                "some_other_field": "some_other_value"
1267            },
1268            "unknown_type": {
1269                "type": "custom",
1270                "value": "test"
1271            },
1272            "invalid_int_from_invalid_string": {
1273                "type": "integer",
1274                "value": "abc"
1275            },
1276            "invalid_int": {
1277                "type": "integer",
1278                "value": 9223372036854775808
1279            },
1280            "missing_type": {
1281                "value": "value with missing type"
1282            },
1283            "missing_value": {
1284                "type": "string"
1285            },
1286            "supported_array_string": {
1287                "type": "array",
1288                "value": ["foo", "bar"]
1289            },
1290            "supported_array_double": {
1291                "type": "array",
1292                "value": [3, 3.0, 3]
1293            },
1294            "supported_array_null": {
1295                "type": "array",
1296                "value": [null, null]
1297            },
1298            "unsupported_array_mixed": {
1299                "type": "array",
1300                "value": ["foo", 1.0]
1301            },
1302            "unsupported_array_object": {
1303                "type": "array",
1304                "value": [{}]
1305            },
1306            "unsupported_array_in_array": {
1307                "type": "array",
1308                "value": [[]]
1309            }
1310        }"#;
1311
1312        let mut attributes = Annotated::<Attributes>::from_json(json).unwrap();
1313        normalize_attribute_types(&mut attributes);
1314
1315        assert_annotated_snapshot!(attributes, @r#"
1316        {
1317          "double_with_i64": {
1318            "type": "double",
1319            "value": -42
1320          },
1321          "invalid_int": null,
1322          "invalid_int_from_invalid_string": null,
1323          "missing_type": null,
1324          "missing_value": null,
1325          "supported_array_double": {
1326            "type": "array",
1327            "value": [
1328              3,
1329              3.0,
1330              3
1331            ]
1332          },
1333          "supported_array_null": {
1334            "type": "array",
1335            "value": [
1336              null,
1337              null
1338            ]
1339          },
1340          "supported_array_string": {
1341            "type": "array",
1342            "value": [
1343              "foo",
1344              "bar"
1345            ]
1346          },
1347          "unknown_type": null,
1348          "unsupported_array_in_array": null,
1349          "unsupported_array_mixed": null,
1350          "unsupported_array_object": null,
1351          "valid_bool": {
1352            "type": "boolean",
1353            "value": true
1354          },
1355          "valid_double": {
1356            "type": "double",
1357            "value": 42.5
1358          },
1359          "valid_double_with_u64": {
1360            "type": "double",
1361            "value": 42
1362          },
1363          "valid_int_from_string": null,
1364          "valid_int_i64": {
1365            "type": "integer",
1366            "value": -42
1367          },
1368          "valid_int_u64": {
1369            "type": "integer",
1370            "value": 42
1371          },
1372          "valid_string": {
1373            "type": "string",
1374            "value": "test"
1375          },
1376          "valid_string_with_other": {
1377            "type": "string",
1378            "value": "test",
1379            "some_other_field": "some_other_value"
1380          },
1381          "_meta": {
1382            "invalid_int": {
1383              "": {
1384                "err": [
1385                  "invalid_data"
1386                ],
1387                "val": {
1388                  "type": "integer",
1389                  "value": 9223372036854775808
1390                }
1391              }
1392            },
1393            "invalid_int_from_invalid_string": {
1394              "": {
1395                "err": [
1396                  "invalid_data"
1397                ],
1398                "val": {
1399                  "type": "integer",
1400                  "value": "abc"
1401                }
1402              }
1403            },
1404            "missing_type": {
1405              "": {
1406                "err": [
1407                  "missing_attribute"
1408                ],
1409                "val": {
1410                  "type": null,
1411                  "value": "value with missing type"
1412                }
1413              }
1414            },
1415            "missing_value": {
1416              "": {
1417                "err": [
1418                  "missing_attribute"
1419                ],
1420                "val": {
1421                  "type": "string",
1422                  "value": null
1423                }
1424              }
1425            },
1426            "unknown_type": {
1427              "": {
1428                "err": [
1429                  "invalid_data"
1430                ],
1431                "val": {
1432                  "type": "custom",
1433                  "value": "test"
1434                }
1435              }
1436            },
1437            "unsupported_array_in_array": {
1438              "": {
1439                "err": [
1440                  "invalid_data"
1441                ]
1442              }
1443            },
1444            "unsupported_array_mixed": {
1445              "": {
1446                "err": [
1447                  "invalid_data"
1448                ]
1449              }
1450            },
1451            "unsupported_array_object": {
1452              "": {
1453                "err": [
1454                  "invalid_data"
1455                ]
1456              }
1457            },
1458            "valid_int_from_string": {
1459              "": {
1460                "err": [
1461                  "invalid_data"
1462                ],
1463                "val": {
1464                  "type": "integer",
1465                  "value": "42"
1466                }
1467              }
1468            }
1469          }
1470        }
1471        "#);
1472    }
1473
1474    #[test]
1475    fn test_normalize_user_agent_none() {
1476        let mut attributes = Default::default();
1477        normalize_user_agent(
1478            &mut attributes,
1479            Some(ClientUserAgentInfo {
1480                user_agent: Some(
1481                    "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",
1482                ),
1483                ..Default::default()
1484            }),
1485        );
1486
1487        assert_annotated_snapshot!(attributes, @r###"
1488        {
1489          "browser.name": {
1490            "type": "string",
1491            "value": "Chrome"
1492          },
1493          "browser.version": {
1494            "type": "string",
1495            "value": "131.0.0"
1496          },
1497          "user_agent.original": {
1498            "type": "string",
1499            "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"
1500          }
1501        }
1502        "###);
1503    }
1504
1505    #[test]
1506    fn test_normalize_user_agent_existing() {
1507        let mut attributes = Annotated::from_json(
1508            r#"{
1509          "browser.name": {
1510            "type": "string",
1511            "value": "Very Special"
1512          },
1513          "browser.version": {
1514            "type": "string",
1515            "value": "13.3.7"
1516          }
1517        }"#,
1518        )
1519        .unwrap();
1520
1521        normalize_user_agent(
1522            &mut attributes,
1523            Some(ClientUserAgentInfo {
1524                user_agent: Some(
1525                    "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",
1526                ),
1527                ..Default::default()
1528            }),
1529        );
1530
1531        assert_annotated_snapshot!(attributes, @r#"
1532        {
1533          "browser.name": {
1534            "type": "string",
1535            "value": "Very Special"
1536          },
1537          "browser.version": {
1538            "type": "string",
1539            "value": "13.3.7"
1540          }
1541        }
1542        "#
1543        );
1544    }
1545
1546    #[test]
1547    fn test_normalize_user_geo_none() {
1548        let mut attributes = Annotated::from_json(
1549            r#"{
1550          "client.address": {
1551            "type": "string",
1552            "value": "192.168.2.1"
1553          }
1554        }"#,
1555        )
1556        .unwrap();
1557
1558        normalize_user_geo(&mut attributes, |addr| {
1559            Some(Geo {
1560                country_code: "XY".to_owned().into(),
1561                city: addr.to_string().into(),
1562                subdivision: Annotated::empty(),
1563                region: "Illu".to_owned().into(),
1564                other: Default::default(),
1565            })
1566        });
1567
1568        assert_annotated_snapshot!(attributes, @r#"
1569        {
1570          "client.address": {
1571            "type": "string",
1572            "value": "192.168.2.1"
1573          },
1574          "user.geo.city": {
1575            "type": "string",
1576            "value": "192.168.2.1"
1577          },
1578          "user.geo.country_code": {
1579            "type": "string",
1580            "value": "XY"
1581          },
1582          "user.geo.region": {
1583            "type": "string",
1584            "value": "Illu"
1585          }
1586        }
1587        "#);
1588    }
1589
1590    #[test]
1591    fn test_normalize_user_geo_existing() {
1592        let mut attributes = Annotated::from_json(
1593            r#"{
1594          "client.address": {
1595            "type": "string",
1596            "value": "192.168.2.1"
1597          },
1598          "user.geo.city": {
1599            "type": "string",
1600            "value": "Foo Hausen"
1601          }
1602        }"#,
1603        )
1604        .unwrap();
1605
1606        normalize_user_geo(&mut attributes, |_| unreachable!());
1607
1608        assert_annotated_snapshot!(attributes, @r#"
1609        {
1610          "client.address": {
1611            "type": "string",
1612            "value": "192.168.2.1"
1613          },
1614          "user.geo.city": {
1615            "type": "string",
1616            "value": "Foo Hausen"
1617          }
1618        }
1619        "#
1620        );
1621    }
1622
1623    #[test]
1624    fn test_normalize_attributes() {
1625        fn replace_key(fragment: &str) -> String {
1626            format!("placeholder.replaced.{fragment}")
1627        }
1628
1629        fn backfill_key(fragment: &str) -> String {
1630            format!("placeholder.backfilled.{fragment}")
1631        }
1632
1633        fn mock_attribute_info(name: &str) -> Option<(&'static AttributeInfo, Option<&str>)> {
1634            use relay_conventions::ApplyScrubbing;
1635
1636            match name {
1637                "replace.empty" => Some((
1638                    &AttributeInfo {
1639                        write_behavior: WriteBehavior::NewName(ReplacementName::Static("replaced")),
1640                        apply_scrubbing: ApplyScrubbing::Manual,
1641                        aliases: &["replaced"],
1642                    },
1643                    None,
1644                )),
1645                "replace.existing" => Some((
1646                    &AttributeInfo {
1647                        write_behavior: WriteBehavior::NewName(ReplacementName::Static(
1648                            "not.replaced",
1649                        )),
1650                        apply_scrubbing: ApplyScrubbing::Manual,
1651                        aliases: &["not.replaced"],
1652                    },
1653                    None,
1654                )),
1655                "backfill.empty" => Some((
1656                    &AttributeInfo {
1657                        write_behavior: WriteBehavior::BothNames(ReplacementName::Static(
1658                            "backfilled",
1659                        )),
1660                        apply_scrubbing: ApplyScrubbing::Manual,
1661                        aliases: &["backfilled"],
1662                    },
1663                    None,
1664                )),
1665                "backfill.existing" => Some((
1666                    &AttributeInfo {
1667                        write_behavior: WriteBehavior::BothNames(ReplacementName::Static(
1668                            "not.backfilled",
1669                        )),
1670                        apply_scrubbing: ApplyScrubbing::Manual,
1671                        aliases: &["not.backfilled"],
1672                    },
1673                    None,
1674                )),
1675                _ if let Some(fragment) = name.strip_prefix("placeholder.replace.") => Some((
1676                    &AttributeInfo {
1677                        write_behavior: WriteBehavior::NewName(ReplacementName::Dynamic(
1678                            replace_key,
1679                        )),
1680                        apply_scrubbing: ApplyScrubbing::Manual,
1681                        aliases: &["placeholder.replaced.<key>"],
1682                    },
1683                    Some(fragment),
1684                )),
1685                _ if let Some(fragment) = name.strip_prefix("placeholder.backfill.") => Some((
1686                    &AttributeInfo {
1687                        write_behavior: WriteBehavior::BothNames(ReplacementName::Dynamic(
1688                            backfill_key,
1689                        )),
1690                        apply_scrubbing: ApplyScrubbing::Manual,
1691                        aliases: &["placeholder.backfilled.<key>"],
1692                    },
1693                    Some(fragment),
1694                )),
1695
1696                _ => None,
1697            }
1698        }
1699
1700        let mut attributes = Attributes::from([
1701            (
1702                "replace.empty".to_owned(),
1703                Annotated::new("Should be moved".to_owned().into()),
1704            ),
1705            (
1706                "replace.existing".to_owned(),
1707                Annotated::new("Should be removed".to_owned().into()),
1708            ),
1709            (
1710                "placeholder.replace.foo".to_owned(),
1711                Annotated::new("Should be moved".to_owned().into()),
1712            ),
1713            (
1714                "not.replaced".to_owned(),
1715                Annotated::new("Should be left alone".to_owned().into()),
1716            ),
1717            (
1718                "backfill.empty".to_owned(),
1719                Annotated::new("Should be copied".to_owned().into()),
1720            ),
1721            (
1722                "backfill.existing".to_owned(),
1723                Annotated::new("Should be left alone".to_owned().into()),
1724            ),
1725            (
1726                "placeholder.backfill.bar".to_owned(),
1727                Annotated::new("Should be copied".to_owned().into()),
1728            ),
1729            (
1730                "not.backfilled".to_owned(),
1731                Annotated::new("Should be left alone".to_owned().into()),
1732            ),
1733        ]);
1734
1735        normalize_attribute_names_inner(&mut attributes.0, mock_attribute_info);
1736
1737        assert_annotated_snapshot!(Annotated::new(attributes), @r###"
1738        {
1739          "backfill.empty": {
1740            "type": "string",
1741            "value": "Should be copied"
1742          },
1743          "backfill.existing": {
1744            "type": "string",
1745            "value": "Should be left alone"
1746          },
1747          "backfilled": {
1748            "type": "string",
1749            "value": "Should be copied"
1750          },
1751          "not.backfilled": {
1752            "type": "string",
1753            "value": "Should be left alone"
1754          },
1755          "not.replaced": {
1756            "type": "string",
1757            "value": "Should be left alone"
1758          },
1759          "placeholder.backfill.bar": {
1760            "type": "string",
1761            "value": "Should be copied"
1762          },
1763          "placeholder.backfilled.bar": {
1764            "type": "string",
1765            "value": "Should be copied"
1766          },
1767          "placeholder.replace.foo": null,
1768          "placeholder.replaced.foo": {
1769            "type": "string",
1770            "value": "Should be moved"
1771          },
1772          "replace.empty": null,
1773          "replace.existing": null,
1774          "replaced": {
1775            "type": "string",
1776            "value": "Should be moved"
1777          },
1778          "_meta": {
1779            "placeholder.replace.foo": {
1780              "": {
1781                "rem": [
1782                  [
1783                    "attribute.deprecated",
1784                    "x"
1785                  ]
1786                ]
1787              }
1788            },
1789            "replace.empty": {
1790              "": {
1791                "rem": [
1792                  [
1793                    "attribute.deprecated",
1794                    "x"
1795                  ]
1796                ]
1797              }
1798            },
1799            "replace.existing": {
1800              "": {
1801                "rem": [
1802                  [
1803                    "attribute.deprecated",
1804                    "x"
1805                  ]
1806                ]
1807              }
1808            }
1809          }
1810        }
1811        "###);
1812    }
1813
1814    #[test]
1815    fn test_normalize_span_infers_op() {
1816        let mut attributes = Annotated::<Attributes>::from_json(
1817            r#"{
1818          "db.system.name": {
1819                "type": "string",
1820                "value": "mysql"
1821            },
1822            "db.operation.name": {
1823                "type": "string",
1824                "value": "query"
1825            }
1826        }
1827        "#,
1828        )
1829        .unwrap();
1830
1831        normalize_sentry_op(&mut attributes);
1832
1833        assert_annotated_snapshot!(attributes, @r#"
1834        {
1835          "db.operation.name": {
1836            "type": "string",
1837            "value": "query"
1838          },
1839          "db.system.name": {
1840            "type": "string",
1841            "value": "mysql"
1842          },
1843          "sentry.op": {
1844            "type": "string",
1845            "value": "db"
1846          }
1847        }
1848        "#);
1849    }
1850
1851    #[test]
1852    fn test_normalize_attribute_values_mysql_db_query_attributes() {
1853        let mut attributes = Annotated::<Attributes>::from_json(
1854            r#"
1855        {
1856          "sentry.op": {
1857            "type": "string",
1858            "value": "db.query"
1859          },
1860          "sentry.origin": {
1861            "type": "string",
1862            "value": "auto.otlp.spans"
1863          },
1864          "db.system.name": {
1865            "type": "string",
1866            "value": "mysql"
1867          },
1868          "db.query.text": {
1869            "type": "string",
1870            "value": "SELECT \"not an identifier\""
1871          }
1872        }
1873        "#,
1874        )
1875        .unwrap();
1876
1877        normalize_db_attributes(&mut attributes);
1878
1879        assert_annotated_snapshot!(attributes, @r#"
1880        {
1881          "db.operation.name": {
1882            "type": "string",
1883            "value": "SELECT"
1884          },
1885          "db.query.text": {
1886            "type": "string",
1887            "value": "SELECT \"not an identifier\""
1888          },
1889          "db.system.name": {
1890            "type": "string",
1891            "value": "mysql"
1892          },
1893          "sentry.normalized_db_query": {
1894            "type": "string",
1895            "value": "SELECT %s"
1896          },
1897          "sentry.normalized_db_query.hash": {
1898            "type": "string",
1899            "value": "3a377dcc490b1690"
1900          },
1901          "sentry.op": {
1902            "type": "string",
1903            "value": "db.query"
1904          },
1905          "sentry.origin": {
1906            "type": "string",
1907            "value": "auto.otlp.spans"
1908          }
1909        }
1910        "#);
1911    }
1912
1913    #[test]
1914    fn test_normalize_mongodb_db_query_attributes() {
1915        let mut attributes = Annotated::<Attributes>::from_json(
1916            r#"
1917        {
1918          "sentry.op": {
1919            "type": "string",
1920            "value": "db"
1921          },
1922          "db.system.name": {
1923            "type": "string",
1924            "value": "mongodb"
1925          },
1926          "db.query.text": {
1927            "type": "string",
1928            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1929          },
1930          "db.operation.name": {
1931            "type": "string",
1932            "value": "find"
1933          },
1934          "db.collection.name": {
1935            "type": "string",
1936            "value": "documents"
1937          }
1938        }
1939        "#,
1940        )
1941        .unwrap();
1942
1943        normalize_db_attributes(&mut attributes);
1944
1945        assert_annotated_snapshot!(attributes, @r#"
1946        {
1947          "db.collection.name": {
1948            "type": "string",
1949            "value": "documents"
1950          },
1951          "db.operation.name": {
1952            "type": "string",
1953            "value": "FIND"
1954          },
1955          "db.query.text": {
1956            "type": "string",
1957            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1958          },
1959          "db.system.name": {
1960            "type": "string",
1961            "value": "mongodb"
1962          },
1963          "sentry.normalized_db_query": {
1964            "type": "string",
1965            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
1966          },
1967          "sentry.normalized_db_query.hash": {
1968            "type": "string",
1969            "value": "aedc5c7e8cec726b"
1970          },
1971          "sentry.op": {
1972            "type": "string",
1973            "value": "db"
1974          }
1975        }
1976        "#);
1977    }
1978
1979    #[test]
1980    fn test_normalize_db_attributes_does_not_update_attributes_if_already_normalized() {
1981        let mut attributes = Annotated::<Attributes>::from_json(
1982            r#"
1983        {
1984          "db.collection.name": {
1985            "type": "string",
1986            "value": "documents"
1987          },
1988          "db.operation.name": {
1989            "type": "string",
1990            "value": "FIND"
1991          },
1992          "db.query.text": {
1993            "type": "string",
1994            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
1995          },
1996          "db.system.name": {
1997            "type": "string",
1998            "value": "mongodb"
1999          },
2000          "sentry.normalized_db_query": {
2001            "type": "string",
2002            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
2003          },
2004          "sentry.op": {
2005            "type": "string",
2006            "value": "db"
2007          }
2008        }
2009        "#,
2010        )
2011        .unwrap();
2012
2013        normalize_db_attributes(&mut attributes);
2014
2015        insta::assert_json_snapshot!(
2016            SerializableAnnotated(&attributes), @r#"
2017        {
2018          "db.collection.name": {
2019            "type": "string",
2020            "value": "documents"
2021          },
2022          "db.operation.name": {
2023            "type": "string",
2024            "value": "FIND"
2025          },
2026          "db.query.text": {
2027            "type": "string",
2028            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
2029          },
2030          "db.system.name": {
2031            "type": "string",
2032            "value": "mongodb"
2033          },
2034          "sentry.normalized_db_query": {
2035            "type": "string",
2036            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
2037          },
2038          "sentry.op": {
2039            "type": "string",
2040            "value": "db"
2041          }
2042        }
2043        "#
2044        );
2045    }
2046
2047    #[test]
2048    fn test_normalize_db_attributes_does_not_change_non_db_spans() {
2049        let mut attributes = Annotated::<Attributes>::from_json(
2050            r#"
2051        {
2052          "sentry.op": {
2053            "type": "string",
2054            "value": "http.client"
2055          },
2056          "sentry.origin": {
2057            "type": "string",
2058            "value": "auto.otlp.spans"
2059          },
2060          "http.request.method": {
2061            "type": "string",
2062            "value": "GET"
2063          }
2064        }
2065      "#,
2066        )
2067        .unwrap();
2068
2069        normalize_db_attributes(&mut attributes);
2070
2071        assert_annotated_snapshot!(attributes, @r#"
2072        {
2073          "http.request.method": {
2074            "type": "string",
2075            "value": "GET"
2076          },
2077          "sentry.op": {
2078            "type": "string",
2079            "value": "http.client"
2080          },
2081          "sentry.origin": {
2082            "type": "string",
2083            "value": "auto.otlp.spans"
2084          }
2085        }
2086        "#);
2087    }
2088
2089    #[test]
2090    fn test_normalize_http_attributes() {
2091        let mut attributes = Annotated::<Attributes>::from_json(
2092            r#"
2093        {
2094          "sentry.op": {
2095            "type": "string",
2096            "value": "http.client"
2097          },
2098          "sentry.category": {
2099            "type": "string",
2100            "value": "http"
2101          },
2102          "http.request.method": {
2103            "type": "string",
2104            "value": "GET"
2105          },
2106          "url.full": {
2107            "type": "string",
2108            "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
2109          }
2110        }
2111      "#,
2112        )
2113        .unwrap();
2114
2115        normalize_http_attributes(&mut attributes, &[]);
2116
2117        assert_annotated_snapshot!(attributes, @r#"
2118        {
2119          "http.request.method": {
2120            "type": "string",
2121            "value": "GET"
2122          },
2123          "sentry.category": {
2124            "type": "string",
2125            "value": "http"
2126          },
2127          "sentry.op": {
2128            "type": "string",
2129            "value": "http.client"
2130          },
2131          "server.address": {
2132            "type": "string",
2133            "value": "*.xn--85x722f.xn--55qx5d.cn"
2134          },
2135          "url.full": {
2136            "type": "string",
2137            "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
2138          }
2139        }
2140        "#);
2141    }
2142
2143    #[test]
2144    fn test_normalize_http_attributes_server_address() {
2145        let mut attributes = Annotated::<Attributes>::from_json(
2146            r#"
2147        {
2148          "sentry.category": {
2149            "type": "string",
2150            "value": "http"
2151          },
2152          "sentry.op": {
2153            "type": "string",
2154            "value": "http.client"
2155          },
2156          "url.scheme": {
2157            "type": "string",
2158            "value": "https"
2159          },
2160          "server.address": {
2161            "type": "string",
2162            "value": "subdomain.example.com:5688"
2163          },
2164          "http.request.method": {
2165            "type": "string",
2166            "value": "GET"
2167          }
2168        }
2169      "#,
2170        )
2171        .unwrap();
2172
2173        normalize_http_attributes(&mut attributes, &[]);
2174
2175        assert_annotated_snapshot!(attributes, @r#"
2176        {
2177          "http.request.method": {
2178            "type": "string",
2179            "value": "GET"
2180          },
2181          "sentry.category": {
2182            "type": "string",
2183            "value": "http"
2184          },
2185          "sentry.op": {
2186            "type": "string",
2187            "value": "http.client"
2188          },
2189          "server.address": {
2190            "type": "string",
2191            "value": "*.example.com:5688"
2192          },
2193          "url.full": {
2194            "type": "string",
2195            "value": "https://subdomain.example.com:5688"
2196          },
2197          "url.scheme": {
2198            "type": "string",
2199            "value": "https"
2200          }
2201        }
2202        "#);
2203    }
2204
2205    #[test]
2206    fn test_normalize_http_attributes_allowed_hosts() {
2207        let mut attributes = Annotated::<Attributes>::from_json(
2208            r#"
2209        {
2210          "sentry.category": {
2211            "type": "string",
2212            "value": "http"
2213          },
2214          "sentry.op": {
2215            "type": "string",
2216            "value": "http.client"
2217          },
2218          "http.request.method": {
2219            "type": "string",
2220            "value": "GET"
2221          },
2222          "url.full": {
2223            "type": "string",
2224            "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
2225          }
2226        }
2227      "#,
2228        )
2229        .unwrap();
2230
2231        normalize_http_attributes(
2232            &mut attributes,
2233            &["application.www.xn--85x722f.xn--55qx5d.cn".to_owned()],
2234        );
2235
2236        assert_annotated_snapshot!(attributes, @r#"
2237        {
2238          "http.request.method": {
2239            "type": "string",
2240            "value": "GET"
2241          },
2242          "sentry.category": {
2243            "type": "string",
2244            "value": "http"
2245          },
2246          "sentry.op": {
2247            "type": "string",
2248            "value": "http.client"
2249          },
2250          "server.address": {
2251            "type": "string",
2252            "value": "application.www.xn--85x722f.xn--55qx5d.cn"
2253          },
2254          "url.full": {
2255            "type": "string",
2256            "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
2257          }
2258        }
2259        "#);
2260    }
2261
2262    #[test]
2263    fn test_normalize_db_attributes_from_legacy_attributes() {
2264        let mut attributes = Annotated::<Attributes>::from_json(
2265            r#"
2266        {
2267          "sentry.op": {
2268            "type": "string",
2269            "value": "db"
2270          },
2271          "db.system.name": {
2272            "type": "string",
2273            "value": "mongodb"
2274          },
2275          "sentry.description": {
2276            "type": "string",
2277            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
2278          },
2279          "db.operation.name": {
2280            "type": "string",
2281            "value": "find"
2282          },
2283          "db.collection.name": {
2284            "type": "string",
2285            "value": "documents"
2286          }
2287        }
2288        "#,
2289        )
2290        .unwrap();
2291
2292        normalize_db_attributes(&mut attributes);
2293
2294        assert_annotated_snapshot!(attributes, @r#"
2295        {
2296          "db.collection.name": {
2297            "type": "string",
2298            "value": "documents"
2299          },
2300          "db.operation.name": {
2301            "type": "string",
2302            "value": "FIND"
2303          },
2304          "db.system.name": {
2305            "type": "string",
2306            "value": "mongodb"
2307          },
2308          "sentry.description": {
2309            "type": "string",
2310            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
2311          },
2312          "sentry.normalized_db_query": {
2313            "type": "string",
2314            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
2315          },
2316          "sentry.normalized_db_query.hash": {
2317            "type": "string",
2318            "value": "aedc5c7e8cec726b"
2319          },
2320          "sentry.op": {
2321            "type": "string",
2322            "value": "db"
2323          }
2324        }
2325        "#);
2326    }
2327
2328    #[test]
2329    fn test_normalize_http_attributes_from_legacy_attributes() {
2330        let mut attributes = Annotated::<Attributes>::from_json(
2331            r#"
2332        {
2333          "sentry.category": {
2334            "type": "string",
2335            "value": "http"
2336          },
2337          "sentry.op": {
2338            "type": "string",
2339            "value": "http.client"
2340          },
2341          "http.request_method": {
2342            "type": "string",
2343            "value": "GET"
2344          }
2345        }
2346        "#,
2347        )
2348        .unwrap();
2349
2350        normalize_attribute_names(&mut attributes);
2351        normalize_http_attributes(&mut attributes, &[]);
2352
2353        assert_annotated_snapshot!(attributes, @r#"
2354        {
2355          "http.request.method": {
2356            "type": "string",
2357            "value": "GET"
2358          },
2359          "http.request_method": {
2360            "type": "string",
2361            "value": "GET"
2362          },
2363          "sentry.category": {
2364            "type": "string",
2365            "value": "http"
2366          },
2367          "sentry.op": {
2368            "type": "string",
2369            "value": "http.client"
2370          }
2371        }
2372        "#);
2373    }
2374
2375    #[test]
2376    fn test_normalize_http_attributes_from_description() {
2377        let mut attributes = Annotated::<Attributes>::from_json(
2378            r#"
2379        {
2380          "sentry.category": {
2381            "type": "string",
2382            "value": "http"
2383          },
2384          "sentry.op": {
2385            "type": "string",
2386            "value": "http.client"
2387          },
2388          "sentry.description": {
2389            "type": "string",
2390            "value": "GET https://application.www.xn--85x722f.xn--55qx5d.cn"
2391          }
2392        }
2393        "#,
2394        )
2395        .unwrap();
2396
2397        normalize_http_attributes(&mut attributes, &[]);
2398
2399        assert_annotated_snapshot!(attributes, @r#"
2400        {
2401          "http.request.method": {
2402            "type": "string",
2403            "value": "GET"
2404          },
2405          "sentry.category": {
2406            "type": "string",
2407            "value": "http"
2408          },
2409          "sentry.description": {
2410            "type": "string",
2411            "value": "GET https://application.www.xn--85x722f.xn--55qx5d.cn"
2412          },
2413          "sentry.op": {
2414            "type": "string",
2415            "value": "http.client"
2416          },
2417          "server.address": {
2418            "type": "string",
2419            "value": "*.xn--85x722f.xn--55qx5d.cn"
2420          },
2421          "url.full": {
2422            "type": "string",
2423            "value": "https://application.www.xn--85x722f.xn--55qx5d.cn"
2424          }
2425        }
2426        "#);
2427    }
2428
2429    #[test]
2430    fn test_write_legacy_attributes() {
2431        let mut attributes = Annotated::<Attributes>::from_json(
2432            r#"
2433        {
2434          "db.collection.name": {
2435            "type": "string",
2436            "value": "documents"
2437          },
2438          "db.operation.name": {
2439            "type": "string",
2440            "value": "FIND"
2441          },
2442          "db.query.text": {
2443            "type": "string",
2444            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
2445          },
2446          "db.system.name": {
2447            "type": "string",
2448            "value": "mongodb"
2449          },
2450          "sentry.normalized_db_query": {
2451            "type": "string",
2452            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
2453          },
2454          "sentry.normalized_db_query.hash": {
2455            "type": "string",
2456            "value": "aedc5c7e8cec726b"
2457          },
2458          "sentry.op": {
2459            "type": "string",
2460            "value": "db"
2461          }
2462        }
2463        "#,
2464        )
2465        .unwrap();
2466
2467        write_legacy_attributes(&mut attributes);
2468
2469        assert_annotated_snapshot!(attributes, @r#"
2470        {
2471          "db.collection.name": {
2472            "type": "string",
2473            "value": "documents"
2474          },
2475          "db.operation.name": {
2476            "type": "string",
2477            "value": "FIND"
2478          },
2479          "db.query.text": {
2480            "type": "string",
2481            "value": "{\"find\": \"documents\", \"foo\": \"bar\"}"
2482          },
2483          "db.system.name": {
2484            "type": "string",
2485            "value": "mongodb"
2486          },
2487          "sentry.action": {
2488            "type": "string",
2489            "value": "FIND"
2490          },
2491          "sentry.domain": {
2492            "type": "string",
2493            "value": ",documents,"
2494          },
2495          "sentry.normalized_db_query": {
2496            "type": "string",
2497            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
2498          },
2499          "sentry.normalized_db_query.hash": {
2500            "type": "string",
2501            "value": "aedc5c7e8cec726b"
2502          },
2503          "sentry.normalized_description": {
2504            "type": "string",
2505            "value": "{\"find\":\"documents\",\"foo\":\"?\"}"
2506          },
2507          "sentry.op": {
2508            "type": "string",
2509            "value": "db"
2510          }
2511        }
2512        "#);
2513    }
2514
2515    #[test]
2516    fn test_normalize_span_category_explicit() {
2517        // Category is already explicitly set, should not be overwritten
2518        let mut attributes = Annotated::<Attributes>::from_json(
2519            r#"{
2520          "sentry.category": {
2521            "type": "string",
2522            "value": "custom"
2523          },
2524          "sentry.op": {
2525            "type": "string",
2526            "value": "db.query"
2527          }
2528        }"#,
2529        )
2530        .unwrap();
2531
2532        normalize_span_category(&mut attributes);
2533
2534        assert_annotated_snapshot!(attributes, @r#"
2535        {
2536          "sentry.category": {
2537            "type": "string",
2538            "value": "custom"
2539          },
2540          "sentry.op": {
2541            "type": "string",
2542            "value": "db.query"
2543          }
2544        }
2545        "#);
2546    }
2547
2548    #[test]
2549    fn test_normalize_span_category_from_op_db() {
2550        let mut attributes = Annotated::<Attributes>::from_json(
2551            r#"{
2552          "sentry.op": {
2553            "type": "string",
2554            "value": "db.query"
2555          }
2556        }"#,
2557        )
2558        .unwrap();
2559
2560        normalize_span_category(&mut attributes);
2561
2562        assert_annotated_snapshot!(attributes, @r#"
2563        {
2564          "sentry.category": {
2565            "type": "string",
2566            "value": "db"
2567          },
2568          "sentry.op": {
2569            "type": "string",
2570            "value": "db.query"
2571          }
2572        }
2573        "#);
2574    }
2575
2576    #[test]
2577    fn test_normalize_span_category_from_op_http() {
2578        let mut attributes = Annotated::<Attributes>::from_json(
2579            r#"{
2580          "sentry.op": {
2581            "type": "string",
2582            "value": "http.client"
2583          }
2584        }"#,
2585        )
2586        .unwrap();
2587
2588        normalize_span_category(&mut attributes);
2589
2590        assert_annotated_snapshot!(attributes, @r#"
2591        {
2592          "sentry.category": {
2593            "type": "string",
2594            "value": "http"
2595          },
2596          "sentry.op": {
2597            "type": "string",
2598            "value": "http.client"
2599          }
2600        }
2601        "#);
2602    }
2603
2604    #[test]
2605    fn test_normalize_span_category_from_op_ui_framework() {
2606        let mut attributes = Annotated::<Attributes>::from_json(
2607            r#"{
2608          "sentry.op": {
2609            "type": "string",
2610            "value": "ui.react.render"
2611          }
2612        }"#,
2613        )
2614        .unwrap();
2615
2616        normalize_span_category(&mut attributes);
2617
2618        assert_annotated_snapshot!(attributes, @r#"
2619        {
2620          "sentry.category": {
2621            "type": "string",
2622            "value": "ui.react"
2623          },
2624          "sentry.op": {
2625            "type": "string",
2626            "value": "ui.react.render"
2627          }
2628        }
2629        "#);
2630    }
2631
2632    #[test]
2633    fn test_normalize_span_category_from_db_system() {
2634        // Category derived from db.system.name when no op
2635        let mut attributes = Annotated::<Attributes>::from_json(
2636            r#"{
2637          "db.system.name": {
2638            "type": "string",
2639            "value": "mongodb"
2640          }
2641        }"#,
2642        )
2643        .unwrap();
2644
2645        normalize_span_category(&mut attributes);
2646
2647        assert_annotated_snapshot!(attributes, @r#"
2648        {
2649          "db.system.name": {
2650            "type": "string",
2651            "value": "mongodb"
2652          },
2653          "sentry.category": {
2654            "type": "string",
2655            "value": "db"
2656          }
2657        }
2658        "#);
2659    }
2660
2661    #[test]
2662    fn test_normalize_span_category_from_http_method() {
2663        // Category derived from http.request.method when no op or db
2664        let mut attributes = Annotated::<Attributes>::from_json(
2665            r#"{
2666          "http.request.method": {
2667            "type": "string",
2668            "value": "GET"
2669          }
2670        }"#,
2671        )
2672        .unwrap();
2673
2674        normalize_span_category(&mut attributes);
2675
2676        assert_annotated_snapshot!(attributes, @r#"
2677        {
2678          "http.request.method": {
2679            "type": "string",
2680            "value": "GET"
2681          },
2682          "sentry.category": {
2683            "type": "string",
2684            "value": "http"
2685          }
2686        }
2687        "#);
2688    }
2689
2690    #[test]
2691    fn test_normalize_span_category_from_ui_component() {
2692        // Category derived from ui.component_name
2693        let mut attributes = Annotated::<Attributes>::from_json(
2694            r#"{
2695          "ui.component_name": {
2696            "type": "string",
2697            "value": "MyComponent"
2698          }
2699        }"#,
2700        )
2701        .unwrap();
2702
2703        normalize_span_category(&mut attributes);
2704
2705        assert_annotated_snapshot!(attributes, @r#"
2706        {
2707          "sentry.category": {
2708            "type": "string",
2709            "value": "ui"
2710          },
2711          "ui.component_name": {
2712            "type": "string",
2713            "value": "MyComponent"
2714          }
2715        }
2716        "#);
2717    }
2718
2719    #[test]
2720    fn test_normalize_span_category_from_resource() {
2721        // Category derived from resource.render_blocking_status
2722        let mut attributes = Annotated::<Attributes>::from_json(
2723            r#"{
2724          "resource.render_blocking_status": {
2725            "type": "string",
2726            "value": "blocking"
2727          }
2728        }"#,
2729        )
2730        .unwrap();
2731
2732        normalize_span_category(&mut attributes);
2733
2734        assert_annotated_snapshot!(attributes, @r#"
2735        {
2736          "resource.render_blocking_status": {
2737            "type": "string",
2738            "value": "blocking"
2739          },
2740          "sentry.category": {
2741            "type": "string",
2742            "value": "resource"
2743          }
2744        }
2745        "#);
2746    }
2747
2748    #[test]
2749    fn test_normalize_span_category_from_browser_origin() {
2750        // Category derived from sentry.origin with browser metrics value
2751        let mut attributes = Annotated::from_json(
2752            r#"{
2753          "sentry.origin": {
2754            "type": "string",
2755            "value": "auto.ui.browser.metrics"
2756          }
2757        }"#,
2758        )
2759        .unwrap();
2760
2761        normalize_span_category(&mut attributes);
2762
2763        assert_annotated_snapshot!(attributes, @r#"
2764        {
2765          "sentry.category": {
2766            "type": "string",
2767            "value": "browser"
2768          },
2769          "sentry.origin": {
2770            "type": "string",
2771            "value": "auto.ui.browser.metrics"
2772          }
2773        }
2774        "#);
2775    }
2776
2777    #[test]
2778    fn test_normalize_client_address_auto_with_ip() {
2779        let mut attributes = Annotated::from_json(
2780            r#"{
2781          "client.address": {
2782            "type": "string",
2783            "value": "{{auto}}"
2784          }
2785        }"#,
2786        )
2787        .unwrap();
2788
2789        normalize_client_address(&mut attributes, Some("192.168.1.1".parse().unwrap()));
2790
2791        assert_annotated_snapshot!(attributes, @r#"
2792        {
2793          "client.address": {
2794            "type": "string",
2795            "value": "192.168.1.1"
2796          }
2797        }
2798        "#);
2799    }
2800
2801    #[test]
2802    fn test_normalize_client_address_auto_without_ip() {
2803        let mut attributes = Annotated::from_json(
2804            r#"{
2805          "client.address": {
2806            "type": "string",
2807            "value": "{{auto}}"
2808          }
2809        }"#,
2810        )
2811        .unwrap();
2812
2813        normalize_client_address(&mut attributes, None);
2814
2815        assert_annotated_snapshot!(attributes, @r#"
2816        {}
2817        "#);
2818    }
2819
2820    #[test]
2821    fn test_normalize_client_address_explicit_not_replaced() {
2822        let mut attributes = Annotated::from_json(
2823            r#"{
2824          "client.address": {
2825            "type": "string",
2826            "value": "10.0.0.1"
2827          }
2828        }"#,
2829        )
2830        .unwrap();
2831
2832        normalize_client_address(&mut attributes, Some("192.168.1.1".parse().unwrap()));
2833
2834        assert_annotated_snapshot!(attributes, @r#"
2835        {
2836          "client.address": {
2837            "type": "string",
2838            "value": "10.0.0.1"
2839          }
2840        }
2841        "#);
2842    }
2843
2844    #[test]
2845    fn test_normalize_client_address_missing_attribute() {
2846        let mut attributes = Annotated::empty();
2847
2848        normalize_client_address(&mut attributes, Some("192.168.1.1".parse().unwrap()));
2849
2850        assert!(attributes.is_empty());
2851    }
2852
2853    #[test]
2854    fn test_normalize_client_address_auto_with_ipv6() {
2855        let mut attributes = Annotated::from_json(
2856            r#"{
2857          "client.address": {
2858            "type": "string",
2859            "value": "{{auto}}"
2860          }
2861        }"#,
2862        )
2863        .unwrap();
2864
2865        normalize_client_address(&mut attributes, Some("2001:db8::1".parse().unwrap()));
2866
2867        assert_annotated_snapshot!(attributes, @r#"
2868        {
2869          "client.address": {
2870            "type": "string",
2871            "value": "2001:db8::1"
2872          }
2873        }
2874        "#);
2875    }
2876
2877    #[test]
2878    fn test_normalize_inject_client_address_inserts_when_missing() {
2879        let mut attributes = Annotated::empty();
2880
2881        normalize_inject_client_address(&mut attributes, Some("192.168.1.1".parse().unwrap()));
2882
2883        assert_annotated_snapshot!(attributes, @r#"
2884        {
2885          "client.address": {
2886            "type": "string",
2887            "value": "192.168.1.1"
2888          }
2889        }
2890        "#);
2891    }
2892
2893    #[test]
2894    fn test_normalize_inject_client_address_does_not_overwrite() {
2895        let mut attributes = Annotated::from_json(
2896            r#"{
2897          "client.address": {
2898            "type": "string",
2899            "value": "10.0.0.1"
2900          }
2901        }"#,
2902        )
2903        .unwrap();
2904
2905        normalize_inject_client_address(&mut attributes, Some("192.168.1.1".parse().unwrap()));
2906
2907        assert_annotated_snapshot!(attributes, @r#"
2908        {
2909          "client.address": {
2910            "type": "string",
2911            "value": "10.0.0.1"
2912          }
2913        }
2914        "#);
2915    }
2916
2917    #[test]
2918    fn test_normalize_inject_client_address_none_ip() {
2919        let mut attributes = Annotated::from_json(r#"{}"#).unwrap();
2920
2921        normalize_inject_client_address(&mut attributes, None);
2922
2923        assert_annotated_snapshot!(attributes, @r#"
2924        {}
2925        "#);
2926    }
2927
2928    #[test]
2929    fn test_normalize_inject_client_address_ipv6() {
2930        let mut attributes = Annotated::empty();
2931
2932        normalize_inject_client_address(&mut attributes, Some("2001:db8::1".parse().unwrap()));
2933
2934        assert_annotated_snapshot!(attributes, @r#"
2935        {
2936          "client.address": {
2937            "type": "string",
2938            "value": "2001:db8::1"
2939          }
2940        }
2941        "#);
2942    }
2943
2944    #[test]
2945    fn test_normalize_span_category_no_match() {
2946        // No category derived when no relevant attributes are present
2947        let mut attributes = Annotated::<Attributes>::from_json(
2948            r#"{
2949          "some.other.attribute": {
2950            "type": "string",
2951            "value": "value"
2952          }
2953        }"#,
2954        )
2955        .unwrap();
2956
2957        normalize_span_category(&mut attributes);
2958
2959        assert_annotated_snapshot!(attributes, @r#"
2960        {
2961          "some.other.attribute": {
2962            "type": "string",
2963            "value": "value"
2964          }
2965        }
2966        "#);
2967    }
2968
2969    #[test]
2970    fn test_normalize_client_sample_rate_valid() {
2971        let mut attributes = Annotated::from_json(
2972            r#"{
2973          "sentry.client_sample_rate": {
2974            "type": "double",
2975            "value": 1.0
2976          }
2977        }"#,
2978        )
2979        .unwrap();
2980
2981        normalize_client_sample_rate(&mut attributes);
2982
2983        assert_annotated_snapshot!(attributes, @r#"
2984        {
2985          "sentry.client_sample_rate": {
2986            "type": "double",
2987            "value": 1.0
2988          }
2989        }
2990        "#);
2991    }
2992
2993    #[test]
2994    fn test_normalize_client_sample_rate_invalid_too_small() {
2995        let mut attributes = {
2996            let mut attrs = Attributes::new();
2997            attrs.insert(SENTRY__CLIENT_SAMPLE_RATE, 0.0);
2998            Annotated::new(attrs)
2999        };
3000
3001        normalize_client_sample_rate(&mut attributes);
3002
3003        assert_annotated_snapshot!(attributes, @r#"
3004        {
3005          "sentry.client_sample_rate": null,
3006          "_meta": {
3007            "sentry.client_sample_rate": {
3008              "": {
3009                "err": [
3010                  [
3011                    "invalid_data",
3012                    {
3013                      "reason": "expected sample rate > 0.0, <= 1.0"
3014                    }
3015                  ]
3016                ]
3017              }
3018            }
3019          }
3020        }
3021        "#);
3022    }
3023
3024    #[test]
3025    fn test_normalize_client_sample_rate_invalid_too_large() {
3026        let mut attributes = {
3027            let mut attrs = Attributes::new();
3028            attrs.insert(SENTRY__CLIENT_SAMPLE_RATE, 1.1);
3029            Annotated::new(attrs)
3030        };
3031
3032        normalize_client_sample_rate(&mut attributes);
3033
3034        assert_annotated_snapshot!(attributes, @r#"
3035        {
3036          "sentry.client_sample_rate": null,
3037          "_meta": {
3038            "sentry.client_sample_rate": {
3039              "": {
3040                "err": [
3041                  [
3042                    "invalid_data",
3043                    {
3044                      "reason": "expected sample rate > 0.0, <= 1.0"
3045                    }
3046                  ]
3047                ]
3048              }
3049            }
3050          }
3051        }
3052        "#);
3053    }
3054
3055    #[test]
3056    fn test_normalize_client_sample_rate_invalid_type() {
3057        let mut attributes = {
3058            let mut attrs = Attributes::new();
3059            attrs.insert(SENTRY__CLIENT_SAMPLE_RATE, "foobar");
3060            Annotated::new(attrs)
3061        };
3062
3063        normalize_client_sample_rate(&mut attributes);
3064
3065        assert_annotated_snapshot!(attributes, @r#"
3066        {
3067          "sentry.client_sample_rate": null,
3068          "_meta": {
3069            "sentry.client_sample_rate": {
3070              "": {
3071                "err": [
3072                  [
3073                    "invalid_data",
3074                    {
3075                      "reason": "expected sample rate > 0.0, <= 1.0"
3076                    }
3077                  ]
3078                ]
3079              }
3080            }
3081          }
3082        }
3083        "#);
3084    }
3085
3086    #[test]
3087    fn test_normalize_mobile_measurements() {
3088        let json = r#"
3089        {
3090            "frames.slow": {"value": 1, "type": "integer"},
3091            "app.vitals.frames.frozen.count": {"value": 2, "type": "integer"},
3092            "frames.total": {"value": 4, "type": "integer"},
3093            "stall_total_time": {"value": 4000, "type": "integer"}
3094        }
3095        "#;
3096
3097        let mut attributes = Annotated::<Attributes>::from_json(json).unwrap();
3098
3099        normalize_attribute_names(&mut attributes);
3100        normalize_mobile_measurements(&mut attributes, Some(Duration::from_secs(5)));
3101
3102        insta::assert_json_snapshot!(SerializableAnnotated(&attributes),  @r#"
3103        {
3104          "app.vitals.frames.frozen.count": {
3105            "type": "integer",
3106            "value": 2
3107          },
3108          "app.vitals.frames.frozen.rate": {
3109            "type": "double",
3110            "value": 0.5
3111          },
3112          "app.vitals.frames.slow.count": {
3113            "type": "integer",
3114            "value": 1
3115          },
3116          "app.vitals.frames.slow.rate": {
3117            "type": "double",
3118            "value": 0.25
3119          },
3120          "app.vitals.frames.total.count": {
3121            "type": "integer",
3122            "value": 4
3123          },
3124          "app.vitals.stall.duration": {
3125            "type": "integer",
3126            "value": 4000
3127          },
3128          "app.vitals.stall.percentage": {
3129            "type": "double",
3130            "value": 0.8
3131          },
3132          "frames.slow": {
3133            "type": "integer",
3134            "value": 1
3135          },
3136          "frames.total": {
3137            "type": "integer",
3138            "value": 4
3139          },
3140          "stall_total_time": {
3141            "type": "integer",
3142            "value": 4000
3143          }
3144        }
3145        "#);
3146    }
3147}