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.datascrubbing_settings.pii_config();
527 if let Some(config) = pii_config {
528 let mut processor = PiiProcessor::new(config.compiled());
529 process_value(annotated_span, &mut processor, ProcessingState::root())?;
530 }
531
532 Ok(())
533}
534
535#[cfg(test)]
536mod tests {
537 use std::sync::LazyLock;
538
539 use relay_event_schema::protocol::EventId;
540 use relay_event_schema::protocol::Span;
541 use relay_protocol::get_value;
542
543 use super::*;
544
545 #[test]
546 fn segment_no_overwrite() {
547 let mut span: Annotated<Span> = Annotated::from_json(
548 r#"{
549 "is_segment": true,
550 "span_id": "fa90fdead5f74052",
551 "parent_span_id": "fa90fdead5f74051"
552 }"#,
553 )
554 .unwrap();
555 set_segment_attributes(&mut span);
556 assert_eq!(get_value!(span.is_segment!), &true);
557 assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
558 }
559
560 #[test]
561 fn segment_overwrite_because_of_segment_id() {
562 let mut span: Annotated<Span> = Annotated::from_json(
563 r#"{
564 "is_segment": false,
565 "span_id": "fa90fdead5f74052",
566 "segment_id": "fa90fdead5f74052",
567 "parent_span_id": "fa90fdead5f74051"
568 }"#,
569 )
570 .unwrap();
571 set_segment_attributes(&mut span);
572 assert_eq!(get_value!(span.is_segment!), &true);
573 }
574
575 #[test]
576 fn segment_overwrite_because_of_missing_parent() {
577 let mut span: Annotated<Span> = Annotated::from_json(
578 r#"{
579 "is_segment": false,
580 "span_id": "fa90fdead5f74052"
581 }"#,
582 )
583 .unwrap();
584 set_segment_attributes(&mut span);
585 assert_eq!(get_value!(span.is_segment!), &true);
586 assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
587 }
588
589 #[test]
590 fn segment_no_parent_but_segment() {
591 let mut span: Annotated<Span> = Annotated::from_json(
592 r#"{
593 "span_id": "fa90fdead5f74052",
594 "segment_id": "ea90fdead5f74051"
595 }"#,
596 )
597 .unwrap();
598 set_segment_attributes(&mut span);
599 assert_eq!(get_value!(span.is_segment!), &false);
600 assert_eq!(get_value!(span.segment_id!).to_string(), "ea90fdead5f74051");
601 }
602
603 #[test]
604 fn segment_only_parent() {
605 let mut span: Annotated<Span> = Annotated::from_json(
606 r#"{
607 "parent_span_id": "fa90fdead5f74051"
608 }"#,
609 )
610 .unwrap();
611 set_segment_attributes(&mut span);
612 assert_eq!(get_value!(span.is_segment), None);
613 assert_eq!(get_value!(span.segment_id), None);
614 }
615
616 #[test]
617 fn not_segment_but_inp_span() {
618 let mut span: Annotated<Span> = Annotated::from_json(
619 r#"{
620 "op": "ui.interaction.click",
621 "is_segment": false,
622 "parent_span_id": "fa90fdead5f74051"
623 }"#,
624 )
625 .unwrap();
626 set_segment_attributes(&mut span);
627 assert_eq!(get_value!(span.is_segment), None);
628 assert_eq!(get_value!(span.segment_id), None);
629 }
630
631 #[test]
632 fn segment_but_inp_span() {
633 let mut span: Annotated<Span> = Annotated::from_json(
634 r#"{
635 "op": "ui.interaction.click",
636 "segment_id": "fa90fdead5f74051",
637 "is_segment": true,
638 "parent_span_id": "fa90fdead5f74051"
639 }"#,
640 )
641 .unwrap();
642 set_segment_attributes(&mut span);
643 assert_eq!(get_value!(span.is_segment), None);
644 assert_eq!(get_value!(span.segment_id), None);
645 }
646
647 #[test]
648 fn keep_browser_name() {
649 let mut span: Annotated<Span> = Annotated::from_json(
650 r#"{
651 "data": {
652 "browser.name": "foo"
653 }
654 }"#,
655 )
656 .unwrap();
657 populate_ua_fields(
658 span.value_mut().as_mut().unwrap(),
659 None,
660 ClientHints::default(),
661 );
662 assert_eq!(get_value!(span.data.browser_name!), "foo");
663 }
664
665 #[test]
666 fn keep_browser_name_when_ua_present() {
667 let mut span: Annotated<Span> = Annotated::from_json(
668 r#"{
669 "data": {
670 "browser.name": "foo",
671 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
672 }
673 }"#,
674 )
675 .unwrap();
676 populate_ua_fields(
677 span.value_mut().as_mut().unwrap(),
678 None,
679 ClientHints::default(),
680 );
681 assert_eq!(get_value!(span.data.browser_name!), "foo");
682 }
683
684 #[test]
685 fn derive_browser_name() {
686 let mut span: Annotated<Span> = Annotated::from_json(
687 r#"{
688 "data": {
689 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
690 }
691 }"#,
692 )
693 .unwrap();
694 populate_ua_fields(
695 span.value_mut().as_mut().unwrap(),
696 None,
697 ClientHints::default(),
698 );
699 assert_eq!(
700 get_value!(span.data.user_agent_original!),
701 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
702 );
703 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
704 }
705
706 #[test]
707 fn keep_user_agent_when_meta_is_present() {
708 let mut span: Annotated<Span> = Annotated::from_json(
709 r#"{
710 "data": {
711 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
712 }
713 }"#,
714 )
715 .unwrap();
716 populate_ua_fields(
717 span.value_mut().as_mut().unwrap(),
718 Some(
719 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
720 ),
721 ClientHints::default(),
722 );
723 assert_eq!(
724 get_value!(span.data.user_agent_original!),
725 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
726 );
727 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
728 }
729
730 #[test]
731 fn derive_user_agent() {
732 let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
733 populate_ua_fields(
734 span.value_mut().as_mut().unwrap(),
735 Some(
736 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
737 ),
738 ClientHints::default(),
739 );
740 assert_eq!(
741 get_value!(span.data.user_agent_original!),
742 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)"
743 );
744 assert_eq!(get_value!(span.data.browser_name!), "IE");
745 }
746
747 #[test]
748 fn keep_user_agent_when_client_hints_are_present() {
749 let mut span: Annotated<Span> = Annotated::from_json(
750 r#"{
751 "data": {
752 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
753 }
754 }"#,
755 )
756 .unwrap();
757 populate_ua_fields(
758 span.value_mut().as_mut().unwrap(),
759 None,
760 ClientHints {
761 sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
762 ..Default::default()
763 },
764 );
765 assert_eq!(
766 get_value!(span.data.user_agent_original!),
767 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
768 );
769 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
770 }
771
772 #[test]
773 fn derive_client_hints() {
774 let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
775 populate_ua_fields(
776 span.value_mut().as_mut().unwrap(),
777 None,
778 ClientHints {
779 sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
780 ..Default::default()
781 },
782 );
783 assert_eq!(get_value!(span.data.user_agent_original), None);
784 assert_eq!(get_value!(span.data.browser_name!), "Opera");
785 }
786
787 static GEO_LOOKUP: LazyLock<GeoIpLookup> = LazyLock::new(|| {
788 GeoIpLookup::open("../relay-event-normalization/tests/fixtures/GeoIP2-Enterprise-Test.mmdb")
789 .unwrap()
790 });
791
792 fn normalize_config() -> NormalizeSpanConfig<'static> {
793 NormalizeSpanConfig {
794 received_at: DateTime::from_timestamp_nanos(0),
795 timestamp_range: UnixTimestamp::from_datetime(
796 DateTime::<Utc>::from_timestamp_millis(1000).unwrap(),
797 )
798 .unwrap()
799 ..UnixTimestamp::from_datetime(DateTime::<Utc>::MAX_UTC).unwrap(),
800 max_tag_value_size: 200,
801 performance_score: None,
802 measurements: None,
803 ai_model_costs: None,
804 max_name_and_unit_len: 200,
805 tx_name_rules: &[],
806 user_agent: None,
807 client_hints: ClientHints::default(),
808 allowed_hosts: &[],
809 client_ip: Some(IpAddr("2.125.160.216".to_owned())),
810 geo_lookup: &GEO_LOOKUP,
811 span_op_defaults: Default::default(),
812 }
813 }
814
815 #[test]
816 fn user_ip_from_client_ip_without_auto() {
817 let mut span = Annotated::from_json(
818 r#"{
819 "start_timestamp": 0,
820 "timestamp": 1,
821 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
822 "span_id": "922dda2462ea4ac2",
823 "data": {
824 "client.address": "2.125.160.216"
825 }
826 }"#,
827 )
828 .unwrap();
829
830 normalize(&mut span, normalize_config()).unwrap();
831
832 assert_eq!(
833 get_value!(span.data.client_address!).as_str(),
834 "2.125.160.216"
835 );
836 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
837 }
838
839 #[test]
840 fn user_ip_from_client_ip_with_auto() {
841 let mut span = Annotated::from_json(
842 r#"{
843 "start_timestamp": 0,
844 "timestamp": 1,
845 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
846 "span_id": "922dda2462ea4ac2",
847 "data": {
848 "client.address": "{{auto}}"
849 }
850 }"#,
851 )
852 .unwrap();
853
854 normalize(&mut span, normalize_config()).unwrap();
855
856 assert_eq!(
857 get_value!(span.data.client_address!).as_str(),
858 "2.125.160.216"
859 );
860 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
861 }
862
863 #[test]
864 fn user_ip_from_client_ip_with_missing() {
865 let mut span = Annotated::from_json(
866 r#"{
867 "start_timestamp": 0,
868 "timestamp": 1,
869 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
870 "span_id": "922dda2462ea4ac2"
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 exclusive_time_inside_span_data_i64() {
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 "data": {
893 "sentry.exclusive_time": 123
894 }
895 }"#,
896 )
897 .unwrap();
898
899 normalize(&mut span, normalize_config()).unwrap();
900
901 let data = get_value!(span.data!);
902 assert_eq!(data.exclusive_time, Annotated::empty());
903 assert_eq!(*get_value!(span.exclusive_time!), 123.0);
904 }
905
906 #[test]
907 fn exclusive_time_inside_span_data_f64() {
908 let mut span = Annotated::from_json(
909 r#"{
910 "start_timestamp": 0,
911 "timestamp": 1,
912 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
913 "span_id": "922dda2462ea4ac2",
914 "data": {
915 "sentry.exclusive_time": 123.0
916 }
917 }"#,
918 )
919 .unwrap();
920
921 normalize(&mut span, normalize_config()).unwrap();
922
923 let data = get_value!(span.data!);
924 assert_eq!(data.exclusive_time, Annotated::empty());
925 assert_eq!(*get_value!(span.exclusive_time!), 123.0);
926 }
927
928 #[test]
929 fn normalize_inp_spans() {
930 let mut span = Annotated::from_json(
931 r#"{
932 "data": {
933 "sentry.origin": "auto.http.browser.inp",
934 "sentry.op": "ui.interaction.click",
935 "release": "frontend@0735d75a05afe8d34bb0950f17c332eb32988862",
936 "environment": "prod",
937 "profile_id": "480ffcc911174ade9106b40ffbd822f5",
938 "replay_id": "f39c5eb6539f4e49b9ad2b95226bc120",
939 "transaction": "/replays",
940 "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",
941 "sentry.exclusive_time": 128.0
942 },
943 "description": "div.app-3diuwe.e88zkai6 > span.app-ksj0rb.e88zkai4",
944 "op": "ui.interaction.click",
945 "parent_span_id": "88457c3c28f4c0c6",
946 "span_id": "be0e95480798a2a9",
947 "start_timestamp": 1732635523.5048,
948 "timestamp": 1732635523.6328,
949 "trace_id": "bdaf4823d1c74068af238879e31e1be9",
950 "origin": "auto.http.browser.inp",
951 "exclusive_time": 128,
952 "measurements": {
953 "inp": {
954 "value": 128,
955 "unit": "millisecond"
956 }
957 },
958 "segment_id": "88457c3c28f4c0c6"
959 }"#,
960 )
961 .unwrap();
962
963 normalize(&mut span, normalize_config()).unwrap();
964
965 let data = get_value!(span.data!);
966
967 assert_eq!(data.exclusive_time, Annotated::empty());
968 assert_eq!(*get_value!(span.exclusive_time!), 128.0);
969
970 assert_eq!(data.profile_id, Annotated::empty());
971 assert_eq!(
972 get_value!(span.profile_id!),
973 &EventId("480ffcc911174ade9106b40ffbd822f5".parse().unwrap())
974 );
975 }
976}