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