1use 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 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 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 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 process_value(
178 &mut annotated_span,
179 &mut RemoveOtherProcessor,
180 ProcessingState::root(),
181 )
182 .ok();
183
184 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 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#[derive(Clone, Debug)]
237struct NormalizeSpanConfig<'a> {
238 received_at: DateTime<Utc>,
240 timestamp_range: std::ops::Range<UnixTimestamp>,
242 max_tag_value_size: usize,
244 performance_score: Option<&'a PerformanceScoreConfig>,
246 measurements: Option<CombinedMeasurementsConfig<'a>>,
252 ai_model_costs: Option<&'a ModelCosts>,
254 ai_operation_type_map: Option<&'a AiOperationTypeMap>,
256 max_name_and_unit_len: usize,
261 tx_name_rules: &'a [TransactionNameRule],
263 user_agent: Option<String>,
265 client_hints: ClientHints<String>,
267 allowed_hosts: &'a [String],
269 client_ip: Option<IpAddr>,
274 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 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 span.is_segment = (segment_id == span_id).into();
340 } else if span.parent_span_id.is_empty() {
341 span.is_segment = true.into();
343 }
344
345 if span.is_segment.value() == Some(&true) {
347 span.segment_id = span.span_id.clone();
348 }
349}
350
351fn 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 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(×tamp_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 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 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 let is_mobile = false; 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 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
510fn promote_span_data_fields(span: &mut Span) {
512 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}