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