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