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