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