relay_server/services/processor/span/
processing.rs

1//! Contains the processing-only functionality.
2
3use std::error::Error;
4
5use crate::envelope::ItemType;
6use crate::managed::{ItemAction, ManagedEnvelope, TypedEnvelope};
7use crate::metrics_extraction::{event, generic};
8use crate::processing;
9use crate::services::outcome::{DiscardReason, Outcome};
10use crate::services::processor::{ProcessingError, ProcessingExtractedMetrics, SpanGroup};
11use crate::statsd::RelayCounters;
12use crate::utils::SamplingResult;
13use chrono::{DateTime, Utc};
14use relay_base_schema::project::ProjectId;
15use relay_config::Config;
16use relay_dynamic_config::{
17    CombinedMetricExtractionConfig, ErrorBoundary, GlobalConfig, ProjectConfig,
18};
19use relay_event_normalization::span::ai::enrich_ai_span;
20use relay_event_normalization::{
21    BorrowedSpanOpDefaults, ClientHints, CombinedMeasurementsConfig, FromUserAgentInfo,
22    GeoIpLookup, MeasurementsConfig, ModelCosts, PerformanceScoreConfig, RawUserAgentInfo,
23    SchemaProcessor, TimestampProcessor, TransactionNameRule, TransactionsProcessor,
24    TrimmingProcessor, normalize_measurements, normalize_performance_score,
25    normalize_transaction_name, span::tag_extraction, validate_span,
26};
27use relay_event_schema::processor::{ProcessingAction, ProcessingState, process_value};
28use relay_event_schema::protocol::{BrowserContext, Event, EventId, IpAddr, Span, SpanData};
29use relay_metrics::{MetricNamespace, UnixTimestamp};
30use relay_pii::PiiProcessor;
31use relay_protocol::{Annotated, Empty, Value};
32use relay_quotas::DataCategory;
33
34pub async fn process(
35    managed_envelope: &mut TypedEnvelope<SpanGroup>,
36    event: &mut Annotated<Event>,
37    extracted_metrics: &mut ProcessingExtractedMetrics,
38    project_id: ProjectId,
39    ctx: processing::Context<'_>,
40    geo_lookup: &GeoIpLookup,
41) {
42    use relay_event_normalization::RemoveOtherProcessor;
43
44    // If no metrics could be extracted, do not sample anything.
45    let should_sample = matches!(&ctx.project_info.config().metric_extraction, ErrorBoundary::Ok(c) if c.is_supported());
46    let sampling_result = match should_sample {
47        true => {
48            // We only implement trace-based sampling rules for now, which can be computed
49            // once for all spans in the envelope.
50            processing::utils::dynamic_sampling::run(
51                managed_envelope.envelope().headers().dsc(),
52                event.value(),
53                &ctx,
54                None,
55            )
56            .await
57        }
58        false => SamplingResult::Pending,
59    };
60
61    relay_statsd::metric!(
62        counter(RelayCounters::SamplingDecision) += 1,
63        decision = sampling_result.decision().as_str(),
64        item = "span"
65    );
66
67    let span_metrics_extraction_config = match ctx.project_info.config.metric_extraction {
68        ErrorBoundary::Ok(ref config) if config.is_enabled() => Some(config),
69        _ => None,
70    };
71    let normalize_span_config = NormalizeSpanConfig::new(
72        ctx.config,
73        ctx.global_config,
74        ctx.project_info.config(),
75        managed_envelope,
76        managed_envelope
77            .envelope()
78            .meta()
79            .client_addr()
80            .map(IpAddr::from),
81        geo_lookup,
82    );
83
84    let client_ip = managed_envelope.envelope().meta().client_addr();
85    let filter_settings = &ctx.project_info.config.filter_settings;
86    let sampling_decision = sampling_result.decision();
87    let transaction_from_dsc = managed_envelope
88        .envelope()
89        .dsc()
90        .and_then(|dsc| dsc.transaction.clone());
91
92    let mut span_count = 0;
93    managed_envelope.retain_items(|item| {
94        let mut annotated_span = match item.ty() {
95            ItemType::Span => match Annotated::<Span>::from_json_bytes(&item.payload()) {
96                Ok(span) => span,
97                Err(err) => {
98                    relay_log::debug!("failed to parse span: {}", err);
99                    return ItemAction::Drop(Outcome::Invalid(DiscardReason::InvalidJson));
100                }
101            },
102
103            _ => return ItemAction::Keep,
104        };
105
106        if let Err(e) = normalize(&mut annotated_span, normalize_span_config.clone()) {
107            relay_log::debug!("failed to normalize span: {}", e);
108            return ItemAction::Drop(Outcome::Invalid(match e {
109                ProcessingError::ProcessingFailed(ProcessingAction::InvalidTransaction(_))
110                | ProcessingError::InvalidTransaction
111                | ProcessingError::InvalidTimestamp => DiscardReason::InvalidSpan,
112                _ => DiscardReason::Internal,
113            }));
114        };
115
116        if let Some(span) = annotated_span.value() {
117            span_count += 1;
118
119            if let Err(filter_stat_key) = relay_filter::should_filter(
120                span,
121                client_ip,
122                filter_settings,
123                ctx.global_config.filters(),
124            ) {
125                relay_log::trace!(
126                    "filtering span {:?} that matched an inbound filter",
127                    span.span_id
128                );
129                return ItemAction::Drop(Outcome::Filtered(filter_stat_key));
130            }
131        }
132
133        if let Some(config) = span_metrics_extraction_config {
134            let Some(span) = annotated_span.value_mut() else {
135                return ItemAction::Drop(Outcome::Invalid(DiscardReason::Internal));
136            };
137            relay_log::trace!("extracting metrics from standalone span {:?}", span.span_id);
138
139            let ErrorBoundary::Ok(global_metrics_config) = &ctx.global_config.metric_extraction
140            else {
141                return ItemAction::Drop(Outcome::Invalid(DiscardReason::Internal));
142            };
143
144            let metrics = generic::extract_metrics(
145                span,
146                CombinedMetricExtractionConfig::new(global_metrics_config, config),
147            );
148
149            extracted_metrics.extend_project_metrics(metrics, Some(sampling_decision));
150
151            let bucket = event::create_span_root_counter(
152                span,
153                transaction_from_dsc.clone(),
154                1,
155                span.is_segment.value().is_some_and(|s| *s),
156                sampling_decision,
157                project_id,
158            );
159            extracted_metrics.extend_sampling_metrics(bucket, Some(sampling_decision));
160
161            item.set_metrics_extracted(true);
162        }
163
164        if sampling_decision.is_drop() {
165            // Drop silently and not with an outcome, we only want to emit an outcome for the
166            // indexed category if the span was dropped by dynamic sampling.
167            // Dropping through the envelope will emit for both categories.
168            return ItemAction::DropSilently;
169        }
170
171        if let Err(e) = scrub(&mut annotated_span, &ctx.project_info.config) {
172            relay_log::error!("failed to scrub span: {e}");
173        }
174
175        // Remove additional fields.
176        process_value(
177            &mut annotated_span,
178            &mut RemoveOtherProcessor,
179            ProcessingState::root(),
180        )
181        .ok();
182
183        // Validate for kafka (TODO: this should be moved to kafka producer)
184        match processing::transactions::spans::validate(&mut annotated_span) {
185            Ok(res) => res,
186            Err(err) => {
187                relay_log::debug!(
188                    error = &err as &dyn Error,
189                    source = "standalone",
190                    "invalid span"
191                );
192                return ItemAction::Drop(Outcome::Invalid(DiscardReason::InvalidSpan));
193            }
194        };
195
196        let Ok(mut new_item) =
197            processing::transactions::spans::create_span_item(annotated_span, ctx.config)
198        // TODO: move function
199        else {
200            return ItemAction::Drop(Outcome::Invalid(DiscardReason::Internal));
201        };
202
203        new_item.set_metrics_extracted(item.metrics_extracted());
204        *item = new_item;
205
206        ItemAction::Keep
207    });
208
209    if sampling_decision.is_drop() {
210        relay_log::trace!(
211            span_count,
212            ?sampling_result,
213            "Dropped spans because of sampling rule",
214        );
215    }
216
217    if let Some(outcome) = sampling_result.into_dropped_outcome() {
218        managed_envelope.track_outcome(outcome, DataCategory::SpanIndexed, span_count);
219    }
220}
221
222/// Config needed to normalize a standalone span.
223#[derive(Clone, Debug)]
224struct NormalizeSpanConfig<'a> {
225    /// The time at which the event was received in this Relay.
226    received_at: DateTime<Utc>,
227    /// Allowed time range for spans.
228    timestamp_range: std::ops::Range<UnixTimestamp>,
229    /// The maximum allowed size of tag values in bytes. Longer values will be cropped.
230    max_tag_value_size: usize,
231    /// Configuration for generating performance score measurements for web vitals
232    performance_score: Option<&'a PerformanceScoreConfig>,
233    /// Configuration for measurement normalization in transaction events.
234    ///
235    /// Has an optional [`relay_event_normalization::MeasurementsConfig`] from both the project and the global level.
236    /// If at least one is provided, then normalization will truncate custom measurements
237    /// and add units of known built-in measurements.
238    measurements: Option<CombinedMeasurementsConfig<'a>>,
239    /// Configuration for AI model cost calculation
240    ai_model_costs: Option<&'a ModelCosts>,
241    /// The maximum length for names of custom measurements.
242    ///
243    /// Measurements with longer names are removed from the transaction event and replaced with a
244    /// metadata entry.
245    max_name_and_unit_len: usize,
246    /// Transaction name normalization rules.
247    tx_name_rules: &'a [TransactionNameRule],
248    /// The user agent parsed from the request.
249    user_agent: Option<String>,
250    /// Client hints parsed from the request.
251    client_hints: ClientHints<String>,
252    /// Hosts that are not replaced by "*" in HTTP span grouping.
253    allowed_hosts: &'a [String],
254    /// The IP address of the SDK that sent the event.
255    ///
256    /// When `{{auto}}` is specified and there is no other IP address in the payload, such as in the
257    /// `request` context, this IP address gets added to `span.data.client_address`.
258    client_ip: Option<IpAddr>,
259    /// An initialized GeoIP lookup.
260    geo_lookup: &'a GeoIpLookup,
261    span_op_defaults: BorrowedSpanOpDefaults<'a>,
262}
263
264impl<'a> NormalizeSpanConfig<'a> {
265    fn new(
266        config: &'a Config,
267        global_config: &'a GlobalConfig,
268        project_config: &'a ProjectConfig,
269        managed_envelope: &ManagedEnvelope,
270        client_ip: Option<IpAddr>,
271        geo_lookup: &'a GeoIpLookup,
272    ) -> Self {
273        let aggregator_config = config.aggregator_config_for(MetricNamespace::Spans);
274
275        Self {
276            received_at: managed_envelope.received_at(),
277            timestamp_range: aggregator_config.timestamp_range(),
278            max_tag_value_size: aggregator_config.max_tag_value_length,
279            performance_score: project_config.performance_score.as_ref(),
280            measurements: Some(CombinedMeasurementsConfig::new(
281                project_config.measurements.as_ref(),
282                global_config.measurements.as_ref(),
283            )),
284            ai_model_costs: global_config.ai_model_costs.as_ref().ok(),
285            max_name_and_unit_len: aggregator_config
286                .max_name_length
287                .saturating_sub(MeasurementsConfig::MEASUREMENT_MRI_OVERHEAD),
288
289            tx_name_rules: &project_config.tx_name_rules,
290            user_agent: managed_envelope
291                .envelope()
292                .meta()
293                .user_agent()
294                .map(Into::into),
295            client_hints: managed_envelope.meta().client_hints().to_owned(),
296            allowed_hosts: global_config.options.http_span_allowed_hosts.as_slice(),
297            client_ip,
298            geo_lookup,
299            span_op_defaults: global_config.span_op_defaults.borrow(),
300        }
301    }
302}
303
304fn set_segment_attributes(span: &mut Annotated<Span>) {
305    let Some(span) = span.value_mut() else { return };
306
307    // Identify INP spans or other WebVital spans and make sure they are not wrapped in a segment.
308    if let Some(span_op) = span.op.value()
309        && (span_op.starts_with("ui.interaction.") || span_op.starts_with("ui.webvital."))
310    {
311        span.is_segment = None.into();
312        span.parent_span_id = None.into();
313        span.segment_id = None.into();
314        return;
315    }
316
317    let Some(span_id) = span.span_id.value() else {
318        return;
319    };
320
321    if let Some(segment_id) = span.segment_id.value() {
322        // The span is a segment if and only if the segment_id matches the span_id.
323        span.is_segment = (segment_id == span_id).into();
324    } else if span.parent_span_id.is_empty() {
325        // If the span has no parent, it is automatically a segment:
326        span.is_segment = true.into();
327    }
328
329    // If the span is a segment, always set the segment_id to the current span_id:
330    if span.is_segment.value() == Some(&true) {
331        span.segment_id = span.span_id.clone();
332    }
333}
334
335/// Normalizes a standalone span.
336fn normalize(
337    annotated_span: &mut Annotated<Span>,
338    config: NormalizeSpanConfig,
339) -> Result<(), ProcessingError> {
340    let NormalizeSpanConfig {
341        received_at,
342        timestamp_range,
343        max_tag_value_size,
344        performance_score,
345        measurements,
346        ai_model_costs,
347        max_name_and_unit_len,
348        tx_name_rules,
349        user_agent,
350        client_hints,
351        allowed_hosts,
352        client_ip,
353        geo_lookup,
354        span_op_defaults,
355    } = config;
356
357    set_segment_attributes(annotated_span);
358
359    // This follows the steps of `event::normalize`.
360
361    process_value(
362        annotated_span,
363        &mut SchemaProcessor::new(),
364        ProcessingState::root(),
365    )?;
366
367    process_value(
368        annotated_span,
369        &mut TimestampProcessor,
370        ProcessingState::root(),
371    )?;
372
373    if let Some(span) = annotated_span.value() {
374        validate_span(span, Some(&timestamp_range))?;
375    }
376    process_value(
377        annotated_span,
378        &mut TransactionsProcessor::new(Default::default(), span_op_defaults),
379        ProcessingState::root(),
380    )?;
381
382    let Some(span) = annotated_span.value_mut() else {
383        return Err(ProcessingError::NoEventPayload);
384    };
385
386    // Replace missing / {{auto}} IPs:
387    // Transaction and error events require an explicit `{{auto}}` to derive the IP, but
388    // for spans we derive it by default:
389    if let Some(client_ip) = client_ip.as_ref() {
390        let ip = span.data.value().and_then(|d| d.client_address.value());
391        if ip.is_none_or(|ip| ip.is_auto()) {
392            span.data
393                .get_or_insert_with(Default::default)
394                .client_address = Annotated::new(client_ip.clone());
395        }
396    }
397
398    // Derive geo ip:
399    let data = span.data.get_or_insert_with(Default::default);
400    if let Some(ip) = data
401        .client_address
402        .value()
403        .and_then(|ip| ip.as_str().parse().ok())
404        && let Some(geo) = geo_lookup.lookup(ip)
405    {
406        data.user_geo_city = geo.city;
407        data.user_geo_country_code = geo.country_code;
408        data.user_geo_region = geo.region;
409        data.user_geo_subdivision = geo.subdivision;
410    }
411
412    populate_ua_fields(span, user_agent.as_deref(), client_hints.as_deref());
413
414    promote_span_data_fields(span);
415
416    if let Annotated(Some(ref mut measurement_values), ref mut meta) = span.measurements {
417        normalize_measurements(
418            measurement_values,
419            meta,
420            measurements,
421            Some(max_name_and_unit_len),
422            span.start_timestamp.0,
423            span.timestamp.0,
424        );
425    }
426
427    span.received = Annotated::new(received_at.into());
428
429    if let Some(transaction) = span
430        .data
431        .value_mut()
432        .as_mut()
433        .map(|data| &mut data.segment_name)
434    {
435        normalize_transaction_name(transaction, tx_name_rules);
436    }
437
438    // Tag extraction:
439    let is_mobile = false; // TODO: find a way to determine is_mobile from a standalone span.
440    let tags = tag_extraction::extract_tags(
441        span,
442        max_tag_value_size,
443        None,
444        None,
445        is_mobile,
446        None,
447        allowed_hosts,
448        geo_lookup,
449    );
450    span.sentry_tags = Annotated::new(tags);
451
452    normalize_performance_score(span, performance_score);
453
454    enrich_ai_span(span, ai_model_costs);
455
456    tag_extraction::extract_measurements(span, is_mobile);
457
458    process_value(
459        annotated_span,
460        &mut TrimmingProcessor::new(),
461        ProcessingState::root(),
462    )?;
463
464    Ok(())
465}
466
467fn populate_ua_fields(
468    span: &mut Span,
469    request_user_agent: Option<&str>,
470    mut client_hints: ClientHints<&str>,
471) {
472    let data = span.data.value_mut().get_or_insert_with(SpanData::default);
473
474    let user_agent = data.user_agent_original.value_mut();
475    if user_agent.is_none() {
476        *user_agent = request_user_agent.map(String::from);
477    } else {
478        // User agent in span payload should take precendence over request
479        // client hints.
480        client_hints = ClientHints::default();
481    }
482
483    if data.browser_name.value().is_none()
484        && let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
485            user_agent: user_agent.as_deref(),
486            client_hints,
487        })
488    {
489        data.browser_name = context.name;
490    }
491}
492
493/// Promotes some fields from span.data as there are predefined places for certain fields.
494fn promote_span_data_fields(span: &mut Span) {
495    // INP spans sets some top level span attributes inside span.data so make sure to pull
496    // them out to the top level before further processing.
497    if let Some(data) = span.data.value_mut() {
498        if let Some(exclusive_time) = match data.exclusive_time.value() {
499            Some(Value::I64(exclusive_time)) => Some(*exclusive_time as f64),
500            Some(Value::U64(exclusive_time)) => Some(*exclusive_time as f64),
501            Some(Value::F64(exclusive_time)) => Some(*exclusive_time),
502            _ => None,
503        } {
504            span.exclusive_time = exclusive_time.into();
505            data.exclusive_time.set_value(None);
506        }
507
508        if let Some(profile_id) = match data.profile_id.value() {
509            Some(Value::String(profile_id)) => profile_id.parse().map(EventId).ok(),
510            _ => None,
511        } {
512            span.profile_id = profile_id.into();
513            data.profile_id.set_value(None);
514        }
515    }
516}
517
518fn scrub(
519    annotated_span: &mut Annotated<Span>,
520    project_config: &ProjectConfig,
521) -> Result<(), ProcessingError> {
522    if let Some(ref config) = project_config.pii_config {
523        let mut processor = PiiProcessor::new(config.compiled());
524        process_value(annotated_span, &mut processor, ProcessingState::root())?;
525    }
526    let pii_config = project_config
527        .datascrubbing_settings
528        .pii_config()
529        .map_err(|e| ProcessingError::PiiConfigError(e.clone()))?;
530    if let Some(config) = pii_config {
531        let mut processor = PiiProcessor::new(config.compiled());
532        process_value(annotated_span, &mut processor, ProcessingState::root())?;
533    }
534
535    Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540    use std::sync::LazyLock;
541
542    use relay_event_schema::protocol::EventId;
543    use relay_event_schema::protocol::Span;
544    use relay_protocol::get_value;
545
546    use super::*;
547
548    #[test]
549    fn segment_no_overwrite() {
550        let mut span: Annotated<Span> = Annotated::from_json(
551            r#"{
552            "is_segment": true,
553            "span_id": "fa90fdead5f74052",
554            "parent_span_id": "fa90fdead5f74051"
555        }"#,
556        )
557        .unwrap();
558        set_segment_attributes(&mut span);
559        assert_eq!(get_value!(span.is_segment!), &true);
560        assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
561    }
562
563    #[test]
564    fn segment_overwrite_because_of_segment_id() {
565        let mut span: Annotated<Span> = Annotated::from_json(
566            r#"{
567         "is_segment": false,
568         "span_id": "fa90fdead5f74052",
569         "segment_id": "fa90fdead5f74052",
570         "parent_span_id": "fa90fdead5f74051"
571     }"#,
572        )
573        .unwrap();
574        set_segment_attributes(&mut span);
575        assert_eq!(get_value!(span.is_segment!), &true);
576    }
577
578    #[test]
579    fn segment_overwrite_because_of_missing_parent() {
580        let mut span: Annotated<Span> = Annotated::from_json(
581            r#"{
582         "is_segment": false,
583         "span_id": "fa90fdead5f74052"
584     }"#,
585        )
586        .unwrap();
587        set_segment_attributes(&mut span);
588        assert_eq!(get_value!(span.is_segment!), &true);
589        assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
590    }
591
592    #[test]
593    fn segment_no_parent_but_segment() {
594        let mut span: Annotated<Span> = Annotated::from_json(
595            r#"{
596         "span_id": "fa90fdead5f74052",
597         "segment_id": "ea90fdead5f74051"
598     }"#,
599        )
600        .unwrap();
601        set_segment_attributes(&mut span);
602        assert_eq!(get_value!(span.is_segment!), &false);
603        assert_eq!(get_value!(span.segment_id!).to_string(), "ea90fdead5f74051");
604    }
605
606    #[test]
607    fn segment_only_parent() {
608        let mut span: Annotated<Span> = Annotated::from_json(
609            r#"{
610         "parent_span_id": "fa90fdead5f74051"
611     }"#,
612        )
613        .unwrap();
614        set_segment_attributes(&mut span);
615        assert_eq!(get_value!(span.is_segment), None);
616        assert_eq!(get_value!(span.segment_id), None);
617    }
618
619    #[test]
620    fn not_segment_but_inp_span() {
621        let mut span: Annotated<Span> = Annotated::from_json(
622            r#"{
623         "op": "ui.interaction.click",
624         "is_segment": false,
625         "parent_span_id": "fa90fdead5f74051"
626     }"#,
627        )
628        .unwrap();
629        set_segment_attributes(&mut span);
630        assert_eq!(get_value!(span.is_segment), None);
631        assert_eq!(get_value!(span.segment_id), None);
632    }
633
634    #[test]
635    fn segment_but_inp_span() {
636        let mut span: Annotated<Span> = Annotated::from_json(
637            r#"{
638         "op": "ui.interaction.click",
639         "segment_id": "fa90fdead5f74051",
640         "is_segment": true,
641         "parent_span_id": "fa90fdead5f74051"
642     }"#,
643        )
644        .unwrap();
645        set_segment_attributes(&mut span);
646        assert_eq!(get_value!(span.is_segment), None);
647        assert_eq!(get_value!(span.segment_id), None);
648    }
649
650    #[test]
651    fn keep_browser_name() {
652        let mut span: Annotated<Span> = Annotated::from_json(
653            r#"{
654                "data": {
655                    "browser.name": "foo"
656                }
657            }"#,
658        )
659        .unwrap();
660        populate_ua_fields(
661            span.value_mut().as_mut().unwrap(),
662            None,
663            ClientHints::default(),
664        );
665        assert_eq!(get_value!(span.data.browser_name!), "foo");
666    }
667
668    #[test]
669    fn keep_browser_name_when_ua_present() {
670        let mut span: Annotated<Span> = Annotated::from_json(
671            r#"{
672                "data": {
673                    "browser.name": "foo",
674                    "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
675                }
676            }"#,
677        )
678            .unwrap();
679        populate_ua_fields(
680            span.value_mut().as_mut().unwrap(),
681            None,
682            ClientHints::default(),
683        );
684        assert_eq!(get_value!(span.data.browser_name!), "foo");
685    }
686
687    #[test]
688    fn derive_browser_name() {
689        let mut span: Annotated<Span> = Annotated::from_json(
690            r#"{
691                "data": {
692                    "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
693                }
694            }"#,
695        )
696            .unwrap();
697        populate_ua_fields(
698            span.value_mut().as_mut().unwrap(),
699            None,
700            ClientHints::default(),
701        );
702        assert_eq!(
703            get_value!(span.data.user_agent_original!),
704            "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
705        );
706        assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
707    }
708
709    #[test]
710    fn keep_user_agent_when_meta_is_present() {
711        let mut span: Annotated<Span> = Annotated::from_json(
712            r#"{
713                "data": {
714                    "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
715                }
716            }"#,
717        )
718            .unwrap();
719        populate_ua_fields(
720            span.value_mut().as_mut().unwrap(),
721            Some(
722                "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
723            ),
724            ClientHints::default(),
725        );
726        assert_eq!(
727            get_value!(span.data.user_agent_original!),
728            "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
729        );
730        assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
731    }
732
733    #[test]
734    fn derive_user_agent() {
735        let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
736        populate_ua_fields(
737            span.value_mut().as_mut().unwrap(),
738            Some(
739                "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
740            ),
741            ClientHints::default(),
742        );
743        assert_eq!(
744            get_value!(span.data.user_agent_original!),
745            "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)"
746        );
747        assert_eq!(get_value!(span.data.browser_name!), "IE");
748    }
749
750    #[test]
751    fn keep_user_agent_when_client_hints_are_present() {
752        let mut span: Annotated<Span> = Annotated::from_json(
753            r#"{
754                "data": {
755                    "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
756                }
757            }"#,
758        )
759            .unwrap();
760        populate_ua_fields(
761            span.value_mut().as_mut().unwrap(),
762            None,
763            ClientHints {
764                sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
765                ..Default::default()
766            },
767        );
768        assert_eq!(
769            get_value!(span.data.user_agent_original!),
770            "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
771        );
772        assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
773    }
774
775    #[test]
776    fn derive_client_hints() {
777        let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
778        populate_ua_fields(
779            span.value_mut().as_mut().unwrap(),
780            None,
781            ClientHints {
782                sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
783                ..Default::default()
784            },
785        );
786        assert_eq!(get_value!(span.data.user_agent_original), None);
787        assert_eq!(get_value!(span.data.browser_name!), "Opera");
788    }
789
790    static GEO_LOOKUP: LazyLock<GeoIpLookup> = LazyLock::new(|| {
791        GeoIpLookup::open("../relay-event-normalization/tests/fixtures/GeoIP2-Enterprise-Test.mmdb")
792            .unwrap()
793    });
794
795    fn normalize_config() -> NormalizeSpanConfig<'static> {
796        NormalizeSpanConfig {
797            received_at: DateTime::from_timestamp_nanos(0),
798            timestamp_range: UnixTimestamp::from_datetime(
799                DateTime::<Utc>::from_timestamp_millis(1000).unwrap(),
800            )
801            .unwrap()
802                ..UnixTimestamp::from_datetime(DateTime::<Utc>::MAX_UTC).unwrap(),
803            max_tag_value_size: 200,
804            performance_score: None,
805            measurements: None,
806            ai_model_costs: None,
807            max_name_and_unit_len: 200,
808            tx_name_rules: &[],
809            user_agent: None,
810            client_hints: ClientHints::default(),
811            allowed_hosts: &[],
812            client_ip: Some(IpAddr("2.125.160.216".to_owned())),
813            geo_lookup: &GEO_LOOKUP,
814            span_op_defaults: Default::default(),
815        }
816    }
817
818    #[test]
819    fn user_ip_from_client_ip_without_auto() {
820        let mut span = Annotated::from_json(
821            r#"{
822            "start_timestamp": 0,
823            "timestamp": 1,
824            "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
825            "span_id": "922dda2462ea4ac2",
826            "data": {
827                "client.address": "2.125.160.216"
828            }
829        }"#,
830        )
831        .unwrap();
832
833        normalize(&mut span, normalize_config()).unwrap();
834
835        assert_eq!(
836            get_value!(span.data.client_address!).as_str(),
837            "2.125.160.216"
838        );
839        assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
840    }
841
842    #[test]
843    fn user_ip_from_client_ip_with_auto() {
844        let mut span = Annotated::from_json(
845            r#"{
846            "start_timestamp": 0,
847            "timestamp": 1,
848            "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
849            "span_id": "922dda2462ea4ac2",
850            "data": {
851                "client.address": "{{auto}}"
852            }
853        }"#,
854        )
855        .unwrap();
856
857        normalize(&mut span, normalize_config()).unwrap();
858
859        assert_eq!(
860            get_value!(span.data.client_address!).as_str(),
861            "2.125.160.216"
862        );
863        assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
864    }
865
866    #[test]
867    fn user_ip_from_client_ip_with_missing() {
868        let mut span = Annotated::from_json(
869            r#"{
870            "start_timestamp": 0,
871            "timestamp": 1,
872            "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
873            "span_id": "922dda2462ea4ac2"
874        }"#,
875        )
876        .unwrap();
877
878        normalize(&mut span, normalize_config()).unwrap();
879
880        assert_eq!(
881            get_value!(span.data.client_address!).as_str(),
882            "2.125.160.216"
883        );
884        assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
885    }
886
887    #[test]
888    fn exclusive_time_inside_span_data_i64() {
889        let mut span = Annotated::from_json(
890            r#"{
891            "start_timestamp": 0,
892            "timestamp": 1,
893            "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
894            "span_id": "922dda2462ea4ac2",
895            "data": {
896                "sentry.exclusive_time": 123
897            }
898        }"#,
899        )
900        .unwrap();
901
902        normalize(&mut span, normalize_config()).unwrap();
903
904        let data = get_value!(span.data!);
905        assert_eq!(data.exclusive_time, Annotated::empty());
906        assert_eq!(*get_value!(span.exclusive_time!), 123.0);
907    }
908
909    #[test]
910    fn exclusive_time_inside_span_data_f64() {
911        let mut span = Annotated::from_json(
912            r#"{
913            "start_timestamp": 0,
914            "timestamp": 1,
915            "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
916            "span_id": "922dda2462ea4ac2",
917            "data": {
918                "sentry.exclusive_time": 123.0
919            }
920        }"#,
921        )
922        .unwrap();
923
924        normalize(&mut span, normalize_config()).unwrap();
925
926        let data = get_value!(span.data!);
927        assert_eq!(data.exclusive_time, Annotated::empty());
928        assert_eq!(*get_value!(span.exclusive_time!), 123.0);
929    }
930
931    #[test]
932    fn normalize_inp_spans() {
933        let mut span = Annotated::from_json(
934            r#"{
935              "data": {
936                "sentry.origin": "auto.http.browser.inp",
937                "sentry.op": "ui.interaction.click",
938                "release": "frontend@0735d75a05afe8d34bb0950f17c332eb32988862",
939                "environment": "prod",
940                "profile_id": "480ffcc911174ade9106b40ffbd822f5",
941                "replay_id": "f39c5eb6539f4e49b9ad2b95226bc120",
942                "transaction": "/replays",
943                "user_agent.original": "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",
944                "sentry.exclusive_time": 128.0
945              },
946              "description": "div.app-3diuwe.e88zkai6 > span.app-ksj0rb.e88zkai4",
947              "op": "ui.interaction.click",
948              "parent_span_id": "88457c3c28f4c0c6",
949              "span_id": "be0e95480798a2a9",
950              "start_timestamp": 1732635523.5048,
951              "timestamp": 1732635523.6328,
952              "trace_id": "bdaf4823d1c74068af238879e31e1be9",
953              "origin": "auto.http.browser.inp",
954              "exclusive_time": 128,
955              "measurements": {
956                "inp": {
957                  "value": 128,
958                  "unit": "millisecond"
959                }
960              },
961              "segment_id": "88457c3c28f4c0c6"
962        }"#,
963        )
964            .unwrap();
965
966        normalize(&mut span, normalize_config()).unwrap();
967
968        let data = get_value!(span.data!);
969
970        assert_eq!(data.exclusive_time, Annotated::empty());
971        assert_eq!(*get_value!(span.exclusive_time!), 128.0);
972
973        assert_eq!(data.profile_id, Annotated::empty());
974        assert_eq!(
975            get_value!(span.profile_id!),
976            &EventId("480ffcc911174ade9106b40ffbd822f5".parse().unwrap())
977        );
978    }
979}