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_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 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 ai_operation_type_map: Option<&'a AiOperationTypeMap>,
243 max_name_and_unit_len: usize,
248 tx_name_rules: &'a [TransactionNameRule],
250 user_agent: Option<String>,
252 client_hints: ClientHints<String>,
254 allowed_hosts: &'a [String],
256 client_ip: Option<IpAddr>,
261 geo_lookup: &'a GeoIpLookup,
263 span_op_defaults: BorrowedSpanOpDefaults<'a>,
264}
265
266impl<'a> NormalizeSpanConfig<'a> {
267 fn new(
268 config: &'a Config,
269 global_config: &'a GlobalConfig,
270 project_config: &'a ProjectConfig,
271 managed_envelope: &ManagedEnvelope,
272 client_ip: Option<IpAddr>,
273 geo_lookup: &'a GeoIpLookup,
274 ) -> Self {
275 let aggregator_config = config.aggregator_config_for(MetricNamespace::Spans);
276
277 Self {
278 received_at: managed_envelope.received_at(),
279 timestamp_range: aggregator_config.timestamp_range(),
280 max_tag_value_size: aggregator_config.max_tag_value_length,
281 performance_score: project_config.performance_score.as_ref(),
282 measurements: Some(CombinedMeasurementsConfig::new(
283 project_config.measurements.as_ref(),
284 global_config.measurements.as_ref(),
285 )),
286 ai_model_costs: global_config.ai_model_costs.as_ref().ok(),
287 ai_operation_type_map: global_config.ai_operation_type_map.as_ref().ok(),
288 max_name_and_unit_len: aggregator_config
289 .max_name_length
290 .saturating_sub(MeasurementsConfig::MEASUREMENT_MRI_OVERHEAD),
291
292 tx_name_rules: &project_config.tx_name_rules,
293 user_agent: managed_envelope
294 .envelope()
295 .meta()
296 .user_agent()
297 .map(Into::into),
298 client_hints: managed_envelope.meta().client_hints().to_owned(),
299 allowed_hosts: global_config.options.http_span_allowed_hosts.as_slice(),
300 client_ip,
301 geo_lookup,
302 span_op_defaults: global_config.span_op_defaults.borrow(),
303 }
304 }
305}
306
307fn set_segment_attributes(span: &mut Annotated<Span>) {
308 let Some(span) = span.value_mut() else { return };
309
310 if let Some(span_op) = span.op.value()
312 && (span_op.starts_with("ui.interaction.") || span_op.starts_with("ui.webvital."))
313 {
314 span.is_segment = None.into();
315 span.parent_span_id = None.into();
316 span.segment_id = None.into();
317 return;
318 }
319
320 let Some(span_id) = span.span_id.value() else {
321 return;
322 };
323
324 if let Some(segment_id) = span.segment_id.value() {
325 span.is_segment = (segment_id == span_id).into();
327 } else if span.parent_span_id.is_empty() {
328 span.is_segment = true.into();
330 }
331
332 if span.is_segment.value() == Some(&true) {
334 span.segment_id = span.span_id.clone();
335 }
336}
337
338fn normalize(
340 annotated_span: &mut Annotated<Span>,
341 config: NormalizeSpanConfig,
342) -> Result<(), ProcessingError> {
343 let NormalizeSpanConfig {
344 received_at,
345 timestamp_range,
346 max_tag_value_size,
347 performance_score,
348 measurements,
349 ai_model_costs,
350 ai_operation_type_map,
351 max_name_and_unit_len,
352 tx_name_rules,
353 user_agent,
354 client_hints,
355 allowed_hosts,
356 client_ip,
357 geo_lookup,
358 span_op_defaults,
359 } = config;
360
361 set_segment_attributes(annotated_span);
362
363 process_value(
366 annotated_span,
367 &mut SchemaProcessor::new(),
368 ProcessingState::root(),
369 )?;
370
371 process_value(
372 annotated_span,
373 &mut TimestampProcessor,
374 ProcessingState::root(),
375 )?;
376
377 if let Some(span) = annotated_span.value() {
378 validate_span(span, Some(×tamp_range))?;
379 }
380 process_value(
381 annotated_span,
382 &mut TransactionsProcessor::new(Default::default(), span_op_defaults),
383 ProcessingState::root(),
384 )?;
385
386 let Some(span) = annotated_span.value_mut() else {
387 return Err(ProcessingError::NoEventPayload);
388 };
389
390 if let Some(client_ip) = client_ip.as_ref() {
394 let ip = span.data.value().and_then(|d| d.client_address.value());
395 if ip.is_none_or(|ip| ip.is_auto()) {
396 span.data
397 .get_or_insert_with(Default::default)
398 .client_address = Annotated::new(client_ip.clone());
399 }
400 }
401
402 let data = span.data.get_or_insert_with(Default::default);
404 if let Some(ip) = data
405 .client_address
406 .value()
407 .and_then(|ip| ip.as_str().parse().ok())
408 && let Some(geo) = geo_lookup.lookup(ip)
409 {
410 data.user_geo_city = geo.city;
411 data.user_geo_country_code = geo.country_code;
412 data.user_geo_region = geo.region;
413 data.user_geo_subdivision = geo.subdivision;
414 }
415
416 populate_ua_fields(span, user_agent.as_deref(), client_hints.as_deref());
417
418 promote_span_data_fields(span);
419
420 if let Annotated(Some(ref mut measurement_values), ref mut meta) = span.measurements {
421 normalize_measurements(
422 measurement_values,
423 meta,
424 measurements,
425 Some(max_name_and_unit_len),
426 span.start_timestamp.0,
427 span.timestamp.0,
428 );
429 }
430
431 span.received = Annotated::new(received_at.into());
432
433 if let Some(transaction) = span
434 .data
435 .value_mut()
436 .as_mut()
437 .map(|data| &mut data.segment_name)
438 {
439 normalize_transaction_name(transaction, tx_name_rules);
440 }
441
442 let is_mobile = false; let tags = tag_extraction::extract_tags(
445 span,
446 max_tag_value_size,
447 None,
448 None,
449 is_mobile,
450 None,
451 allowed_hosts,
452 geo_lookup,
453 );
454 span.sentry_tags = Annotated::new(tags);
455
456 normalize_performance_score(span, performance_score);
457
458 enrich_ai_span_data(span, ai_model_costs, ai_operation_type_map);
459
460 tag_extraction::extract_measurements(span, is_mobile);
461
462 process_value(
463 annotated_span,
464 &mut TrimmingProcessor::new(),
465 ProcessingState::root(),
466 )?;
467
468 Ok(())
469}
470
471fn populate_ua_fields(
472 span: &mut Span,
473 request_user_agent: Option<&str>,
474 mut client_hints: ClientHints<&str>,
475) {
476 let data = span.data.value_mut().get_or_insert_with(SpanData::default);
477
478 let user_agent = data.user_agent_original.value_mut();
479 if user_agent.is_none() {
480 *user_agent = request_user_agent.map(String::from);
481 } else {
482 client_hints = ClientHints::default();
485 }
486
487 if data.browser_name.value().is_none()
488 && let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
489 user_agent: user_agent.as_deref(),
490 client_hints,
491 })
492 {
493 data.browser_name = context.name;
494 }
495}
496
497fn promote_span_data_fields(span: &mut Span) {
499 if let Some(data) = span.data.value_mut() {
502 if let Some(exclusive_time) = match data.exclusive_time.value() {
503 Some(Value::I64(exclusive_time)) => Some(*exclusive_time as f64),
504 Some(Value::U64(exclusive_time)) => Some(*exclusive_time as f64),
505 Some(Value::F64(exclusive_time)) => Some(*exclusive_time),
506 _ => None,
507 } {
508 span.exclusive_time = exclusive_time.into();
509 data.exclusive_time.set_value(None);
510 }
511
512 if let Some(profile_id) = match data.profile_id.value() {
513 Some(Value::String(profile_id)) => profile_id.parse().map(EventId).ok(),
514 _ => None,
515 } {
516 span.profile_id = profile_id.into();
517 data.profile_id.set_value(None);
518 }
519 }
520}
521
522fn scrub(
523 annotated_span: &mut Annotated<Span>,
524 project_config: &ProjectConfig,
525) -> Result<(), ProcessingError> {
526 if let Some(ref config) = project_config.pii_config {
527 let mut processor = PiiProcessor::new(config.compiled());
528 process_value(annotated_span, &mut processor, ProcessingState::root())?;
529 }
530 let pii_config = project_config
531 .datascrubbing_settings
532 .pii_config()
533 .map_err(|e| ProcessingError::PiiConfigError(e.clone()))?;
534 if let Some(config) = pii_config {
535 let mut processor = PiiProcessor::new(config.compiled());
536 process_value(annotated_span, &mut processor, ProcessingState::root())?;
537 }
538
539 Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544 use std::sync::LazyLock;
545
546 use relay_event_schema::protocol::EventId;
547 use relay_event_schema::protocol::Span;
548 use relay_protocol::get_value;
549
550 use super::*;
551
552 #[test]
553 fn segment_no_overwrite() {
554 let mut span: Annotated<Span> = Annotated::from_json(
555 r#"{
556 "is_segment": true,
557 "span_id": "fa90fdead5f74052",
558 "parent_span_id": "fa90fdead5f74051"
559 }"#,
560 )
561 .unwrap();
562 set_segment_attributes(&mut span);
563 assert_eq!(get_value!(span.is_segment!), &true);
564 assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
565 }
566
567 #[test]
568 fn segment_overwrite_because_of_segment_id() {
569 let mut span: Annotated<Span> = Annotated::from_json(
570 r#"{
571 "is_segment": false,
572 "span_id": "fa90fdead5f74052",
573 "segment_id": "fa90fdead5f74052",
574 "parent_span_id": "fa90fdead5f74051"
575 }"#,
576 )
577 .unwrap();
578 set_segment_attributes(&mut span);
579 assert_eq!(get_value!(span.is_segment!), &true);
580 }
581
582 #[test]
583 fn segment_overwrite_because_of_missing_parent() {
584 let mut span: Annotated<Span> = Annotated::from_json(
585 r#"{
586 "is_segment": false,
587 "span_id": "fa90fdead5f74052"
588 }"#,
589 )
590 .unwrap();
591 set_segment_attributes(&mut span);
592 assert_eq!(get_value!(span.is_segment!), &true);
593 assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
594 }
595
596 #[test]
597 fn segment_no_parent_but_segment() {
598 let mut span: Annotated<Span> = Annotated::from_json(
599 r#"{
600 "span_id": "fa90fdead5f74052",
601 "segment_id": "ea90fdead5f74051"
602 }"#,
603 )
604 .unwrap();
605 set_segment_attributes(&mut span);
606 assert_eq!(get_value!(span.is_segment!), &false);
607 assert_eq!(get_value!(span.segment_id!).to_string(), "ea90fdead5f74051");
608 }
609
610 #[test]
611 fn segment_only_parent() {
612 let mut span: Annotated<Span> = Annotated::from_json(
613 r#"{
614 "parent_span_id": "fa90fdead5f74051"
615 }"#,
616 )
617 .unwrap();
618 set_segment_attributes(&mut span);
619 assert_eq!(get_value!(span.is_segment), None);
620 assert_eq!(get_value!(span.segment_id), None);
621 }
622
623 #[test]
624 fn not_segment_but_inp_span() {
625 let mut span: Annotated<Span> = Annotated::from_json(
626 r#"{
627 "op": "ui.interaction.click",
628 "is_segment": false,
629 "parent_span_id": "fa90fdead5f74051"
630 }"#,
631 )
632 .unwrap();
633 set_segment_attributes(&mut span);
634 assert_eq!(get_value!(span.is_segment), None);
635 assert_eq!(get_value!(span.segment_id), None);
636 }
637
638 #[test]
639 fn segment_but_inp_span() {
640 let mut span: Annotated<Span> = Annotated::from_json(
641 r#"{
642 "op": "ui.interaction.click",
643 "segment_id": "fa90fdead5f74051",
644 "is_segment": true,
645 "parent_span_id": "fa90fdead5f74051"
646 }"#,
647 )
648 .unwrap();
649 set_segment_attributes(&mut span);
650 assert_eq!(get_value!(span.is_segment), None);
651 assert_eq!(get_value!(span.segment_id), None);
652 }
653
654 #[test]
655 fn keep_browser_name() {
656 let mut span: Annotated<Span> = Annotated::from_json(
657 r#"{
658 "data": {
659 "browser.name": "foo"
660 }
661 }"#,
662 )
663 .unwrap();
664 populate_ua_fields(
665 span.value_mut().as_mut().unwrap(),
666 None,
667 ClientHints::default(),
668 );
669 assert_eq!(get_value!(span.data.browser_name!), "foo");
670 }
671
672 #[test]
673 fn keep_browser_name_when_ua_present() {
674 let mut span: Annotated<Span> = Annotated::from_json(
675 r#"{
676 "data": {
677 "browser.name": "foo",
678 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
679 }
680 }"#,
681 )
682 .unwrap();
683 populate_ua_fields(
684 span.value_mut().as_mut().unwrap(),
685 None,
686 ClientHints::default(),
687 );
688 assert_eq!(get_value!(span.data.browser_name!), "foo");
689 }
690
691 #[test]
692 fn derive_browser_name() {
693 let mut span: Annotated<Span> = Annotated::from_json(
694 r#"{
695 "data": {
696 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
697 }
698 }"#,
699 )
700 .unwrap();
701 populate_ua_fields(
702 span.value_mut().as_mut().unwrap(),
703 None,
704 ClientHints::default(),
705 );
706 assert_eq!(
707 get_value!(span.data.user_agent_original!),
708 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
709 );
710 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
711 }
712
713 #[test]
714 fn keep_user_agent_when_meta_is_present() {
715 let mut span: Annotated<Span> = Annotated::from_json(
716 r#"{
717 "data": {
718 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
719 }
720 }"#,
721 )
722 .unwrap();
723 populate_ua_fields(
724 span.value_mut().as_mut().unwrap(),
725 Some(
726 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
727 ),
728 ClientHints::default(),
729 );
730 assert_eq!(
731 get_value!(span.data.user_agent_original!),
732 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
733 );
734 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
735 }
736
737 #[test]
738 fn derive_user_agent() {
739 let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
740 populate_ua_fields(
741 span.value_mut().as_mut().unwrap(),
742 Some(
743 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
744 ),
745 ClientHints::default(),
746 );
747 assert_eq!(
748 get_value!(span.data.user_agent_original!),
749 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)"
750 );
751 assert_eq!(get_value!(span.data.browser_name!), "IE");
752 }
753
754 #[test]
755 fn keep_user_agent_when_client_hints_are_present() {
756 let mut span: Annotated<Span> = Annotated::from_json(
757 r#"{
758 "data": {
759 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
760 }
761 }"#,
762 )
763 .unwrap();
764 populate_ua_fields(
765 span.value_mut().as_mut().unwrap(),
766 None,
767 ClientHints {
768 sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
769 ..Default::default()
770 },
771 );
772 assert_eq!(
773 get_value!(span.data.user_agent_original!),
774 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
775 );
776 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
777 }
778
779 #[test]
780 fn derive_client_hints() {
781 let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
782 populate_ua_fields(
783 span.value_mut().as_mut().unwrap(),
784 None,
785 ClientHints {
786 sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
787 ..Default::default()
788 },
789 );
790 assert_eq!(get_value!(span.data.user_agent_original), None);
791 assert_eq!(get_value!(span.data.browser_name!), "Opera");
792 }
793
794 static GEO_LOOKUP: LazyLock<GeoIpLookup> = LazyLock::new(|| {
795 GeoIpLookup::open("../relay-event-normalization/tests/fixtures/GeoIP2-Enterprise-Test.mmdb")
796 .unwrap()
797 });
798
799 fn normalize_config() -> NormalizeSpanConfig<'static> {
800 NormalizeSpanConfig {
801 received_at: DateTime::from_timestamp_nanos(0),
802 timestamp_range: UnixTimestamp::from_datetime(
803 DateTime::<Utc>::from_timestamp_millis(1000).unwrap(),
804 )
805 .unwrap()
806 ..UnixTimestamp::from_datetime(DateTime::<Utc>::MAX_UTC).unwrap(),
807 max_tag_value_size: 200,
808 performance_score: None,
809 measurements: None,
810 ai_model_costs: None,
811 ai_operation_type_map: None,
812 max_name_and_unit_len: 200,
813 tx_name_rules: &[],
814 user_agent: None,
815 client_hints: ClientHints::default(),
816 allowed_hosts: &[],
817 client_ip: Some(IpAddr("2.125.160.216".to_owned())),
818 geo_lookup: &GEO_LOOKUP,
819 span_op_defaults: Default::default(),
820 }
821 }
822
823 #[test]
824 fn user_ip_from_client_ip_without_auto() {
825 let mut span = Annotated::from_json(
826 r#"{
827 "start_timestamp": 0,
828 "timestamp": 1,
829 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
830 "span_id": "922dda2462ea4ac2",
831 "data": {
832 "client.address": "2.125.160.216"
833 }
834 }"#,
835 )
836 .unwrap();
837
838 normalize(&mut span, normalize_config()).unwrap();
839
840 assert_eq!(
841 get_value!(span.data.client_address!).as_str(),
842 "2.125.160.216"
843 );
844 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
845 }
846
847 #[test]
848 fn user_ip_from_client_ip_with_auto() {
849 let mut span = Annotated::from_json(
850 r#"{
851 "start_timestamp": 0,
852 "timestamp": 1,
853 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
854 "span_id": "922dda2462ea4ac2",
855 "data": {
856 "client.address": "{{auto}}"
857 }
858 }"#,
859 )
860 .unwrap();
861
862 normalize(&mut span, normalize_config()).unwrap();
863
864 assert_eq!(
865 get_value!(span.data.client_address!).as_str(),
866 "2.125.160.216"
867 );
868 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
869 }
870
871 #[test]
872 fn user_ip_from_client_ip_with_missing() {
873 let mut span = Annotated::from_json(
874 r#"{
875 "start_timestamp": 0,
876 "timestamp": 1,
877 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
878 "span_id": "922dda2462ea4ac2"
879 }"#,
880 )
881 .unwrap();
882
883 normalize(&mut span, normalize_config()).unwrap();
884
885 assert_eq!(
886 get_value!(span.data.client_address!).as_str(),
887 "2.125.160.216"
888 );
889 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
890 }
891
892 #[test]
893 fn exclusive_time_inside_span_data_i64() {
894 let mut span = Annotated::from_json(
895 r#"{
896 "start_timestamp": 0,
897 "timestamp": 1,
898 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
899 "span_id": "922dda2462ea4ac2",
900 "data": {
901 "sentry.exclusive_time": 123
902 }
903 }"#,
904 )
905 .unwrap();
906
907 normalize(&mut span, normalize_config()).unwrap();
908
909 let data = get_value!(span.data!);
910 assert_eq!(data.exclusive_time, Annotated::empty());
911 assert_eq!(*get_value!(span.exclusive_time!), 123.0);
912 }
913
914 #[test]
915 fn exclusive_time_inside_span_data_f64() {
916 let mut span = Annotated::from_json(
917 r#"{
918 "start_timestamp": 0,
919 "timestamp": 1,
920 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
921 "span_id": "922dda2462ea4ac2",
922 "data": {
923 "sentry.exclusive_time": 123.0
924 }
925 }"#,
926 )
927 .unwrap();
928
929 normalize(&mut span, normalize_config()).unwrap();
930
931 let data = get_value!(span.data!);
932 assert_eq!(data.exclusive_time, Annotated::empty());
933 assert_eq!(*get_value!(span.exclusive_time!), 123.0);
934 }
935
936 #[test]
937 fn normalize_inp_spans() {
938 let mut span = Annotated::from_json(
939 r#"{
940 "data": {
941 "sentry.origin": "auto.http.browser.inp",
942 "sentry.op": "ui.interaction.click",
943 "release": "frontend@0735d75a05afe8d34bb0950f17c332eb32988862",
944 "environment": "prod",
945 "profile_id": "480ffcc911174ade9106b40ffbd822f5",
946 "replay_id": "f39c5eb6539f4e49b9ad2b95226bc120",
947 "transaction": "/replays",
948 "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",
949 "sentry.exclusive_time": 128.0
950 },
951 "description": "div.app-3diuwe.e88zkai6 > span.app-ksj0rb.e88zkai4",
952 "op": "ui.interaction.click",
953 "parent_span_id": "88457c3c28f4c0c6",
954 "span_id": "be0e95480798a2a9",
955 "start_timestamp": 1732635523.5048,
956 "timestamp": 1732635523.6328,
957 "trace_id": "bdaf4823d1c74068af238879e31e1be9",
958 "origin": "auto.http.browser.inp",
959 "exclusive_time": 128,
960 "measurements": {
961 "inp": {
962 "value": 128,
963 "unit": "millisecond"
964 }
965 },
966 "segment_id": "88457c3c28f4c0c6"
967 }"#,
968 )
969 .unwrap();
970
971 normalize(&mut span, normalize_config()).unwrap();
972
973 let data = get_value!(span.data!);
974
975 assert_eq!(data.exclusive_time, Annotated::empty());
976 assert_eq!(*get_value!(span.exclusive_time!), 128.0);
977
978 assert_eq!(data.profile_id, Annotated::empty());
979 assert_eq!(
980 get_value!(span.profile_id!),
981 &EventId("480ffcc911174ade9106b40ffbd822f5".parse().unwrap())
982 );
983 }
984}