1use std::error::Error;
4use std::sync::Arc;
5
6use crate::envelope::{ContentType, Item, ItemType};
7use crate::managed::{ItemAction, ManagedEnvelope, TypedEnvelope};
8use crate::metrics_extraction::{event, generic};
9use crate::services::outcome::{DiscardReason, Outcome};
10use crate::services::processor::span::extract_transaction_span;
11use crate::services::processor::{
12 EventMetricsExtracted, ProcessingError, ProcessingExtractedMetrics, SpanGroup, SpansExtracted,
13 TransactionGroup, dynamic_sampling, event_type,
14};
15use crate::services::projects::project::ProjectInfo;
16use crate::utils::sample;
17use chrono::{DateTime, Utc};
18use relay_base_schema::events::EventType;
19use relay_base_schema::project::ProjectId;
20use relay_config::Config;
21use relay_dynamic_config::{
22 CombinedMetricExtractionConfig, ErrorBoundary, Feature, GlobalConfig, ProjectConfig,
23};
24use relay_event_normalization::span::ai::{extract_ai_data, map_ai_measurements_to_data};
25use relay_event_normalization::{
26 BorrowedSpanOpDefaults, ClientHints, CombinedMeasurementsConfig, FromUserAgentInfo,
27 GeoIpLookup, MeasurementsConfig, ModelCosts, PerformanceScoreConfig, RawUserAgentInfo,
28 SchemaProcessor, TimestampProcessor, TransactionNameRule, TransactionsProcessor,
29 TrimmingProcessor, normalize_measurements, normalize_performance_score,
30 normalize_transaction_name, span::tag_extraction, validate_span,
31};
32use relay_event_schema::processor::{ProcessingAction, ProcessingState, process_value};
33use relay_event_schema::protocol::{
34 BrowserContext, Event, EventId, IpAddr, Measurement, Measurements, Span, SpanData,
35};
36use relay_log::protocol::{Attachment, AttachmentType};
37use relay_metrics::{FractionUnit, MetricNamespace, MetricUnit, UnixTimestamp};
38use relay_pii::PiiProcessor;
39use relay_protocol::{Annotated, Empty, Value};
40use relay_quotas::DataCategory;
41use relay_sampling::evaluation::ReservoirEvaluator;
42use relay_spans::otel_trace::Span as OtelSpan;
43use thiserror::Error;
44
45#[derive(Error, Debug)]
46#[error(transparent)]
47struct ValidationError(#[from] anyhow::Error);
48
49#[allow(clippy::too_many_arguments)]
50pub async fn process(
51 managed_envelope: &mut TypedEnvelope<SpanGroup>,
52 event: &mut Annotated<Event>,
53 extracted_metrics: &mut ProcessingExtractedMetrics,
54 global_config: &GlobalConfig,
55 config: Arc<Config>,
56 project_id: ProjectId,
57 project_info: Arc<ProjectInfo>,
58 sampling_project_info: Option<Arc<ProjectInfo>>,
59 geo_lookup: Option<&GeoIpLookup>,
60 reservoir_counters: &ReservoirEvaluator<'_>,
61) {
62 use relay_event_normalization::RemoveOtherProcessor;
63
64 let sampling_result = dynamic_sampling::run(
67 managed_envelope,
68 event,
69 config.clone(),
70 project_info.clone(),
71 sampling_project_info,
72 reservoir_counters,
73 )
74 .await;
75
76 let span_metrics_extraction_config = match project_info.config.metric_extraction {
77 ErrorBoundary::Ok(ref config) if config.is_enabled() => Some(config),
78 _ => None,
79 };
80 let normalize_span_config = NormalizeSpanConfig::new(
81 &config,
82 global_config,
83 project_info.config(),
84 managed_envelope,
85 managed_envelope
86 .envelope()
87 .meta()
88 .client_addr()
89 .map(IpAddr::from),
90 geo_lookup,
91 );
92
93 let client_ip = managed_envelope.envelope().meta().client_addr();
94 let filter_settings = &project_info.config.filter_settings;
95 let sampling_decision = sampling_result.decision();
96
97 let mut span_count = 0;
98 managed_envelope.retain_items(|item| {
99 let mut annotated_span = match item.ty() {
100 ItemType::OtelSpan => match serde_json::from_slice::<OtelSpan>(&item.payload()) {
101 Ok(otel_span) => match relay_spans::otel_to_sentry_span(otel_span) {
102 Ok(span) => Annotated::new(span),
103 Err(err) => {
104 relay_log::debug!("failed to convert OTel span to Sentry span: {:?}", err);
105 return ItemAction::Drop(Outcome::Invalid(DiscardReason::InvalidJson));
106 }
107 },
108 Err(err) => {
109 relay_log::debug!("failed to parse OTel span: {}", err);
110 return ItemAction::Drop(Outcome::Invalid(DiscardReason::InvalidJson));
111 }
112 },
113 ItemType::Span => match Annotated::<Span>::from_json_bytes(&item.payload()) {
114 Ok(span) => span,
115 Err(err) => {
116 relay_log::debug!("failed to parse span: {}", err);
117 return ItemAction::Drop(Outcome::Invalid(DiscardReason::InvalidJson));
118 }
119 },
120
121 _ => return ItemAction::Keep,
122 };
123
124 if let Err(e) = normalize(&mut annotated_span, normalize_span_config.clone()) {
125 relay_log::debug!("failed to normalize span: {}", e);
126 return ItemAction::Drop(Outcome::Invalid(match e {
127 ProcessingError::ProcessingFailed(ProcessingAction::InvalidTransaction(_))
128 | ProcessingError::InvalidTransaction
129 | ProcessingError::InvalidTimestamp => DiscardReason::InvalidSpan,
130 _ => DiscardReason::Internal,
131 }));
132 };
133
134 if let Some(span) = annotated_span.value() {
135 span_count += 1;
136
137 if let Err(filter_stat_key) = relay_filter::should_filter(
138 span,
139 client_ip,
140 filter_settings,
141 global_config.filters(),
142 ) {
143 relay_log::trace!(
144 "filtering span {:?} that matched an inbound filter",
145 span.span_id
146 );
147 return ItemAction::Drop(Outcome::Filtered(filter_stat_key));
148 }
149 }
150
151 if let Some(config) = span_metrics_extraction_config {
152 let Some(span) = annotated_span.value_mut() else {
153 return ItemAction::Drop(Outcome::Invalid(DiscardReason::Internal));
154 };
155 relay_log::trace!("extracting metrics from standalone span {:?}", span.span_id);
156
157 let ErrorBoundary::Ok(global_metrics_config) = &global_config.metric_extraction else {
158 return ItemAction::Drop(Outcome::Invalid(DiscardReason::Internal));
159 };
160
161 let metrics = generic::extract_metrics(
162 span,
163 CombinedMetricExtractionConfig::new(global_metrics_config, config),
164 );
165
166 extracted_metrics.extend_project_metrics(metrics, Some(sampling_decision));
167
168 if project_info.config.features.produces_spans() {
169 let transaction = span
170 .data
171 .value()
172 .and_then(|d| d.segment_name.value())
173 .cloned();
174 let bucket = event::create_span_root_counter(
175 span,
176 transaction,
177 1,
178 sampling_decision,
179 project_id,
180 );
181 extracted_metrics.extend_sampling_metrics(bucket, Some(sampling_decision));
182 }
183
184 item.set_metrics_extracted(true);
185 }
186
187 if sampling_decision.is_drop() {
188 return ItemAction::DropSilently;
192 }
193
194 if let Err(e) = scrub(&mut annotated_span, &project_info.config) {
195 relay_log::error!("failed to scrub span: {e}");
196 }
197
198 process_value(
200 &mut annotated_span,
201 &mut RemoveOtherProcessor,
202 ProcessingState::root(),
203 )
204 .ok();
205
206 match validate(&mut annotated_span) {
208 Ok(res) => res,
209 Err(err) => {
210 relay_log::with_scope(
211 |scope| {
212 scope.add_attachment(Attachment {
213 buffer: annotated_span.to_json().unwrap_or_default().into(),
214 filename: "span.json".to_owned(),
215 content_type: Some("application/json".to_owned()),
216 ty: Some(AttachmentType::Attachment),
217 })
218 },
219 || {
220 relay_log::error!(
221 error = &err as &dyn Error,
222 source = "standalone",
223 "invalid span"
224 )
225 },
226 );
227 return ItemAction::Drop(Outcome::Invalid(DiscardReason::InvalidSpan));
228 }
229 };
230
231 let mut new_item = Item::new(ItemType::Span);
233 let payload = match annotated_span.to_json() {
234 Ok(payload) => payload,
235 Err(err) => {
236 relay_log::debug!("failed to serialize span: {}", err);
237 return ItemAction::Drop(Outcome::Invalid(DiscardReason::Internal));
238 }
239 };
240 new_item.set_payload(ContentType::Json, payload);
241 new_item.set_metrics_extracted(item.metrics_extracted());
242
243 *item = new_item;
244
245 ItemAction::Keep
246 });
247
248 if sampling_decision.is_drop() {
249 relay_log::trace!(
250 span_count,
251 ?sampling_result,
252 "Dropped spans because of sampling rule",
253 );
254 }
255
256 if let Some(outcome) = sampling_result.into_dropped_outcome() {
257 managed_envelope.track_outcome(outcome, DataCategory::SpanIndexed, span_count);
258 }
259}
260
261fn add_sample_rate(measurements: &mut Annotated<Measurements>, name: &str, value: Option<f64>) {
262 let value = match value {
263 Some(value) if value > 0.0 => value,
264 _ => return,
265 };
266
267 let measurement = Annotated::new(Measurement {
268 value: Annotated::try_from(value),
269 unit: MetricUnit::Fraction(FractionUnit::Ratio).into(),
270 });
271
272 measurements
273 .get_or_insert_with(Measurements::default)
274 .insert(name.to_owned(), measurement);
275}
276
277#[allow(clippy::too_many_arguments)]
278pub fn extract_from_event(
279 managed_envelope: &mut TypedEnvelope<TransactionGroup>,
280 event: &Annotated<Event>,
281 global_config: &GlobalConfig,
282 config: Arc<Config>,
283 server_sample_rate: Option<f64>,
284 event_metrics_extracted: EventMetricsExtracted,
285 spans_extracted: SpansExtracted,
286) -> SpansExtracted {
287 if event_type(event) != Some(EventType::Transaction) {
289 return spans_extracted;
290 };
291
292 if spans_extracted.0 {
293 return spans_extracted;
294 }
295
296 if let Some(sample_rate) = global_config.options.span_extraction_sample_rate {
297 if sample(sample_rate).is_discard() {
298 return spans_extracted;
299 }
300 }
301
302 let client_sample_rate = managed_envelope
303 .envelope()
304 .dsc()
305 .and_then(|ctx| ctx.sample_rate);
306
307 let mut add_span = |mut span: Span| {
308 add_sample_rate(
309 &mut span.measurements,
310 "client_sample_rate",
311 client_sample_rate,
312 );
313 add_sample_rate(
314 &mut span.measurements,
315 "server_sample_rate",
316 server_sample_rate,
317 );
318
319 let mut span = Annotated::new(span);
320
321 match validate(&mut span) {
322 Ok(span) => span,
323 Err(e) => {
324 relay_log::error!(
325 error = &e as &dyn Error,
326 span = ?span,
327 source = "event",
328 "invalid span"
329 );
330
331 managed_envelope.track_outcome(
332 Outcome::Invalid(DiscardReason::InvalidSpan),
333 relay_quotas::DataCategory::SpanIndexed,
334 1,
335 );
336 return;
337 }
338 };
339
340 let span = match span.to_json() {
341 Ok(span) => span,
342 Err(e) => {
343 relay_log::error!(error = &e as &dyn Error, "Failed to serialize span");
344 managed_envelope.track_outcome(
345 Outcome::Invalid(DiscardReason::InvalidSpan),
346 relay_quotas::DataCategory::SpanIndexed,
347 1,
348 );
349 return;
350 }
351 };
352
353 let mut item = Item::new(ItemType::Span);
354 item.set_payload(ContentType::Json, span);
355 item.set_metrics_extracted(event_metrics_extracted.0);
357
358 relay_log::trace!("Adding span to envelope");
359 managed_envelope.envelope_mut().add_item(item);
360 };
361
362 let Some(event) = event.value() else {
363 return spans_extracted;
364 };
365
366 let Some(transaction_span) = extract_transaction_span(
367 event,
368 config
369 .aggregator_config_for(MetricNamespace::Spans)
370 .max_tag_value_length,
371 &[],
372 ) else {
373 return spans_extracted;
374 };
375
376 if let Some(child_spans) = event.spans.value() {
378 for span in child_spans {
379 let Some(inner_span) = span.value() else {
380 continue;
381 };
382 let mut new_span = inner_span.clone();
385 new_span.is_segment = Annotated::new(false);
386 new_span.is_remote = Annotated::new(false);
387 new_span.received = transaction_span.received.clone();
388 new_span.segment_id = transaction_span.segment_id.clone();
389 new_span.platform = transaction_span.platform.clone();
390
391 new_span.profile_id = transaction_span.profile_id.clone();
394
395 add_span(new_span);
396 }
397 }
398
399 add_span(transaction_span);
400
401 SpansExtracted(true)
402}
403
404pub fn maybe_discard_transaction(
406 managed_envelope: &mut TypedEnvelope<TransactionGroup>,
407 event: Annotated<Event>,
408 project_info: Arc<ProjectInfo>,
409) -> Annotated<Event> {
410 if event_type(&event) == Some(EventType::Transaction)
411 && project_info.has_feature(Feature::DiscardTransaction)
412 {
413 managed_envelope.update();
414 return Annotated::empty();
415 }
416
417 event
418}
419#[derive(Clone, Debug)]
421struct NormalizeSpanConfig<'a> {
422 received_at: DateTime<Utc>,
424 timestamp_range: std::ops::Range<UnixTimestamp>,
426 max_tag_value_size: usize,
428 performance_score: Option<&'a PerformanceScoreConfig>,
430 measurements: Option<CombinedMeasurementsConfig<'a>>,
436 ai_model_costs: Option<&'a ModelCosts>,
438 max_name_and_unit_len: usize,
443 tx_name_rules: &'a [TransactionNameRule],
445 user_agent: Option<String>,
447 client_hints: ClientHints<String>,
449 allowed_hosts: &'a [String],
451 client_ip: Option<IpAddr>,
456 geo_lookup: Option<&'a GeoIpLookup>,
458 span_op_defaults: BorrowedSpanOpDefaults<'a>,
459}
460
461impl<'a> NormalizeSpanConfig<'a> {
462 fn new(
463 config: &'a Config,
464 global_config: &'a GlobalConfig,
465 project_config: &'a ProjectConfig,
466 managed_envelope: &ManagedEnvelope,
467 client_ip: Option<IpAddr>,
468 geo_lookup: Option<&'a GeoIpLookup>,
469 ) -> Self {
470 let aggregator_config = config.aggregator_config_for(MetricNamespace::Spans);
471
472 Self {
473 received_at: managed_envelope.received_at(),
474 timestamp_range: aggregator_config.timestamp_range(),
475 max_tag_value_size: aggregator_config.max_tag_value_length,
476 performance_score: project_config.performance_score.as_ref(),
477 measurements: Some(CombinedMeasurementsConfig::new(
478 project_config.measurements.as_ref(),
479 global_config.measurements.as_ref(),
480 )),
481 ai_model_costs: match &global_config.ai_model_costs {
482 ErrorBoundary::Err(_) => None,
483 ErrorBoundary::Ok(costs) => Some(costs),
484 },
485 max_name_and_unit_len: aggregator_config
486 .max_name_length
487 .saturating_sub(MeasurementsConfig::MEASUREMENT_MRI_OVERHEAD),
488
489 tx_name_rules: &project_config.tx_name_rules,
490 user_agent: managed_envelope
491 .envelope()
492 .meta()
493 .user_agent()
494 .map(String::from),
495 client_hints: managed_envelope.meta().client_hints().clone(),
496 allowed_hosts: global_config.options.http_span_allowed_hosts.as_slice(),
497 client_ip,
498 geo_lookup,
499 span_op_defaults: global_config.span_op_defaults.borrow(),
500 }
501 }
502}
503
504fn set_segment_attributes(span: &mut Annotated<Span>) {
505 let Some(span) = span.value_mut() else { return };
506
507 if let Some(span_op) = span.op.value() {
509 if span_op.starts_with("ui.interaction.") || span_op.starts_with("ui.webvital.") {
510 span.is_segment = None.into();
511 span.parent_span_id = None.into();
512 span.segment_id = None.into();
513 return;
514 }
515 }
516
517 let Some(span_id) = span.span_id.value() else {
518 return;
519 };
520
521 if let Some(segment_id) = span.segment_id.value() {
522 span.is_segment = (segment_id == span_id).into();
524 } else if span.parent_span_id.is_empty() {
525 span.is_segment = true.into();
527 }
528
529 if span.is_segment.value() == Some(&true) {
531 span.segment_id = span.span_id.clone();
532 }
533}
534
535fn normalize(
537 annotated_span: &mut Annotated<Span>,
538 config: NormalizeSpanConfig,
539) -> Result<(), ProcessingError> {
540 let NormalizeSpanConfig {
541 received_at,
542 timestamp_range,
543 max_tag_value_size,
544 performance_score,
545 measurements,
546 ai_model_costs,
547 max_name_and_unit_len,
548 tx_name_rules,
549 user_agent,
550 client_hints,
551 allowed_hosts,
552 client_ip,
553 geo_lookup,
554 span_op_defaults,
555 } = config;
556
557 set_segment_attributes(annotated_span);
558
559 process_value(
562 annotated_span,
563 &mut SchemaProcessor,
564 ProcessingState::root(),
565 )?;
566
567 process_value(
568 annotated_span,
569 &mut TimestampProcessor,
570 ProcessingState::root(),
571 )?;
572
573 if let Some(span) = annotated_span.value() {
574 validate_span(span, Some(×tamp_range))?;
575 }
576 process_value(
577 annotated_span,
578 &mut TransactionsProcessor::new(Default::default(), span_op_defaults),
579 ProcessingState::root(),
580 )?;
581
582 let Some(span) = annotated_span.value_mut() else {
583 return Err(ProcessingError::NoEventPayload);
584 };
585
586 if let Some(client_ip) = client_ip.as_ref() {
590 let ip = span.data.value().and_then(|d| d.client_address.value());
591 if ip.is_none_or(|ip| ip.is_auto()) {
592 span.data
593 .get_or_insert_with(Default::default)
594 .client_address = Annotated::new(client_ip.clone());
595 }
596 }
597
598 if let Some(geoip_lookup) = geo_lookup {
600 let data = span.data.get_or_insert_with(Default::default);
601 if let Some(ip) = data.client_address.value() {
602 if let Ok(Some(geo)) = geoip_lookup.lookup(ip.as_str()) {
603 data.user_geo_city = geo.city;
604 data.user_geo_country_code = geo.country_code;
605 data.user_geo_region = geo.region;
606 data.user_geo_subdivision = geo.subdivision;
607 }
608 }
609 }
610
611 populate_ua_fields(span, user_agent.as_deref(), client_hints.as_deref());
612
613 promote_span_data_fields(span);
614
615 if let Annotated(Some(ref mut measurement_values), ref mut meta) = span.measurements {
616 normalize_measurements(
617 measurement_values,
618 meta,
619 measurements,
620 Some(max_name_and_unit_len),
621 span.start_timestamp.0,
622 span.timestamp.0,
623 );
624 }
625
626 span.received = Annotated::new(received_at.into());
627
628 if let Some(transaction) = span
629 .data
630 .value_mut()
631 .as_mut()
632 .map(|data| &mut data.segment_name)
633 {
634 normalize_transaction_name(transaction, tx_name_rules);
635 }
636
637 let is_mobile = false; let tags = tag_extraction::extract_tags(
640 span,
641 max_tag_value_size,
642 None,
643 None,
644 is_mobile,
645 None,
646 allowed_hosts,
647 geo_lookup,
648 );
649 span.sentry_tags = Annotated::new(tags);
650
651 normalize_performance_score(span, performance_score);
652
653 map_ai_measurements_to_data(span);
654 if let Some(model_costs_config) = ai_model_costs {
655 extract_ai_data(span, model_costs_config);
656 }
657
658 tag_extraction::extract_measurements(span, is_mobile);
659
660 process_value(
661 annotated_span,
662 &mut TrimmingProcessor::new(),
663 ProcessingState::root(),
664 )?;
665
666 Ok(())
667}
668
669fn populate_ua_fields(
670 span: &mut Span,
671 request_user_agent: Option<&str>,
672 mut client_hints: ClientHints<&str>,
673) {
674 let data = span.data.value_mut().get_or_insert_with(SpanData::default);
675
676 let user_agent = data.user_agent_original.value_mut();
677 if user_agent.is_none() {
678 *user_agent = request_user_agent.map(String::from);
679 } else {
680 client_hints = ClientHints::default();
683 }
684
685 if data.browser_name.value().is_none() {
686 if let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
687 user_agent: user_agent.as_deref(),
688 client_hints,
689 }) {
690 data.browser_name = context.name;
691 }
692 }
693}
694
695fn promote_span_data_fields(span: &mut Span) {
697 if let Some(data) = span.data.value_mut() {
700 if let Some(exclusive_time) = match data.exclusive_time.value() {
701 Some(Value::I64(exclusive_time)) => Some(*exclusive_time as f64),
702 Some(Value::U64(exclusive_time)) => Some(*exclusive_time as f64),
703 Some(Value::F64(exclusive_time)) => Some(*exclusive_time),
704 _ => None,
705 } {
706 span.exclusive_time = exclusive_time.into();
707 data.exclusive_time.set_value(None);
708 }
709
710 if let Some(profile_id) = match data.profile_id.value() {
711 Some(Value::String(profile_id)) => profile_id.parse().map(EventId).ok(),
712 _ => None,
713 } {
714 span.profile_id = profile_id.into();
715 data.profile_id.set_value(None);
716 }
717 }
718}
719
720fn scrub(
721 annotated_span: &mut Annotated<Span>,
722 project_config: &ProjectConfig,
723) -> Result<(), ProcessingError> {
724 if let Some(ref config) = project_config.pii_config {
725 let mut processor = PiiProcessor::new(config.compiled());
726 process_value(annotated_span, &mut processor, ProcessingState::root())?;
727 }
728 let pii_config = project_config
729 .datascrubbing_settings
730 .pii_config()
731 .map_err(|e| ProcessingError::PiiConfigError(e.clone()))?;
732 if let Some(config) = pii_config {
733 let mut processor = PiiProcessor::new(config.compiled());
734 process_value(annotated_span, &mut processor, ProcessingState::root())?;
735 }
736
737 Ok(())
738}
739
740fn validate(span: &mut Annotated<Span>) -> Result<(), ValidationError> {
742 let inner = span
743 .value_mut()
744 .as_mut()
745 .ok_or(anyhow::anyhow!("empty span"))?;
746 let Span {
747 exclusive_time,
748 tags,
749 sentry_tags,
750 start_timestamp,
751 timestamp,
752 span_id,
753 trace_id,
754 ..
755 } = inner;
756
757 trace_id
758 .value()
759 .ok_or(anyhow::anyhow!("span is missing trace_id"))?;
760 span_id
761 .value()
762 .ok_or(anyhow::anyhow!("span is missing span_id"))?;
763
764 match (start_timestamp.value(), timestamp.value()) {
765 (Some(start), Some(end)) => {
766 if end < start {
767 return Err(ValidationError(anyhow::anyhow!(
768 "end timestamp is smaller than start timestamp"
769 )));
770 }
771 }
772 (_, None) => {
773 return Err(ValidationError(anyhow::anyhow!(
774 "timestamp hard-required for spans"
775 )));
776 }
777 (None, _) => {
778 return Err(ValidationError(anyhow::anyhow!(
779 "start_timestamp hard-required for spans"
780 )));
781 }
782 }
783
784 exclusive_time
785 .value()
786 .ok_or(anyhow::anyhow!("missing exclusive_time"))?;
787
788 if let Some(sentry_tags) = sentry_tags.value_mut() {
789 if sentry_tags
790 .group
791 .value()
792 .is_some_and(|s| s.len() > 16 || s.chars().any(|c| !c.is_ascii_hexdigit()))
793 {
794 sentry_tags.group.set_value(None);
795 }
796
797 if sentry_tags
798 .status_code
799 .value()
800 .is_some_and(|s| s.parse::<u16>().is_err())
801 {
802 sentry_tags.group.set_value(None);
803 }
804 }
805 if let Some(tags) = tags.value_mut() {
806 tags.retain(|_, value| !value.value().is_empty())
807 }
808
809 Ok(())
810}
811
812#[cfg(test)]
813mod tests {
814 use std::collections::BTreeMap;
815 use std::sync::Arc;
816
817 use bytes::Bytes;
818 use once_cell::sync::Lazy;
819 use relay_event_schema::protocol::{Context, ContextInner, EventId, Timestamp, TraceContext};
820 use relay_event_schema::protocol::{Contexts, Event, Span};
821 use relay_protocol::get_value;
822 use relay_system::Addr;
823
824 use crate::envelope::Envelope;
825 use crate::managed::ManagedEnvelope;
826 use crate::services::processor::ProcessingGroup;
827 use crate::services::projects::project::ProjectInfo;
828
829 use super::*;
830
831 fn params() -> (
832 TypedEnvelope<TransactionGroup>,
833 Annotated<Event>,
834 Arc<ProjectInfo>,
835 ) {
836 let bytes = Bytes::from(
837 r#"{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","dsn":"https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42","trace":{"trace_id":"89143b0763095bd9c9955e8175d1fb23","public_key":"e12d836b15bb49d7bbf99e64295d995b","sample_rate":"0.2"}}
838{"type":"transaction"}
839{}
840"#,
841 );
842
843 let dummy_envelope = Envelope::parse_bytes(bytes).unwrap();
844 let mut project_info = ProjectInfo::default();
845 project_info
846 .config
847 .features
848 .0
849 .insert(Feature::ExtractCommonSpanMetricsFromEvent);
850 let project_info = Arc::new(project_info);
851
852 let event = Event {
853 ty: EventType::Transaction.into(),
854 start_timestamp: Timestamp(DateTime::from_timestamp(0, 0).unwrap()).into(),
855 timestamp: Timestamp(DateTime::from_timestamp(1, 0).unwrap()).into(),
856
857 contexts: Contexts(BTreeMap::from([(
858 "trace".into(),
859 ContextInner(Context::Trace(Box::new(TraceContext {
860 trace_id: Annotated::new("4c79f60c11214eb38604f4ae0781bfb2".parse().unwrap()),
861 span_id: Annotated::new("fa90fdead5f74053".parse().unwrap()),
862 exclusive_time: 1000.0.into(),
863 ..Default::default()
864 })))
865 .into(),
866 )]))
867 .into(),
868 ..Default::default()
869 };
870
871 let managed_envelope = ManagedEnvelope::new(dummy_envelope, Addr::dummy(), Addr::dummy());
872 let managed_envelope = (managed_envelope, ProcessingGroup::Transaction)
873 .try_into()
874 .unwrap();
875
876 let event = Annotated::from(event);
877
878 (managed_envelope, event, project_info)
879 }
880
881 #[test]
882 fn extract_sampled_default() {
883 let global_config = GlobalConfig::default();
884 let config = Arc::new(Config::default());
885 assert!(global_config.options.span_extraction_sample_rate.is_none());
886 let (mut managed_envelope, event, _) = params();
887 extract_from_event(
888 &mut managed_envelope,
889 &event,
890 &global_config,
891 config,
892 None,
893 EventMetricsExtracted(false),
894 SpansExtracted(false),
895 );
896 assert!(
897 managed_envelope
898 .envelope()
899 .items()
900 .any(|item| item.ty() == &ItemType::Span),
901 "{:?}",
902 managed_envelope.envelope()
903 );
904 }
905
906 #[test]
907 fn extract_sampled_explicit() {
908 let mut global_config = GlobalConfig::default();
909 global_config.options.span_extraction_sample_rate = Some(1.0);
910 let config = Arc::new(Config::default());
911 let (mut managed_envelope, event, _) = params();
912 extract_from_event(
913 &mut managed_envelope,
914 &event,
915 &global_config,
916 config,
917 None,
918 EventMetricsExtracted(false),
919 SpansExtracted(false),
920 );
921 assert!(
922 managed_envelope
923 .envelope()
924 .items()
925 .any(|item| item.ty() == &ItemType::Span),
926 "{:?}",
927 managed_envelope.envelope()
928 );
929 }
930
931 #[test]
932 fn extract_sampled_dropped() {
933 let mut global_config = GlobalConfig::default();
934 global_config.options.span_extraction_sample_rate = Some(0.0);
935 let config = Arc::new(Config::default());
936 let (mut managed_envelope, event, _) = params();
937 extract_from_event(
938 &mut managed_envelope,
939 &event,
940 &global_config,
941 config,
942 None,
943 EventMetricsExtracted(false),
944 SpansExtracted(false),
945 );
946 assert!(
947 !managed_envelope
948 .envelope()
949 .items()
950 .any(|item| item.ty() == &ItemType::Span),
951 "{:?}",
952 managed_envelope.envelope()
953 );
954 }
955
956 #[test]
957 fn extract_sample_rates() {
958 let mut global_config = GlobalConfig::default();
959 global_config.options.span_extraction_sample_rate = Some(1.0); let config = Arc::new(Config::default());
961 let (mut managed_envelope, event, _) = params(); extract_from_event(
963 &mut managed_envelope,
964 &event,
965 &global_config,
966 config,
967 Some(0.1),
968 EventMetricsExtracted(false),
969 SpansExtracted(false),
970 );
971
972 let span = managed_envelope
973 .envelope()
974 .items()
975 .find(|item| item.ty() == &ItemType::Span)
976 .unwrap();
977
978 let span = Annotated::<Span>::from_json_bytes(&span.payload()).unwrap();
979 let measurements = span.value().and_then(|s| s.measurements.value());
980
981 insta::assert_debug_snapshot!(measurements, @r###"
982 Some(
983 Measurements(
984 {
985 "client_sample_rate": Measurement {
986 value: 0.2,
987 unit: Fraction(
988 Ratio,
989 ),
990 },
991 "server_sample_rate": Measurement {
992 value: 0.1,
993 unit: Fraction(
994 Ratio,
995 ),
996 },
997 },
998 ),
999 )
1000 "###);
1001 }
1002
1003 #[test]
1004 fn segment_no_overwrite() {
1005 let mut span: Annotated<Span> = Annotated::from_json(
1006 r#"{
1007 "is_segment": true,
1008 "span_id": "fa90fdead5f74052",
1009 "parent_span_id": "fa90fdead5f74051"
1010 }"#,
1011 )
1012 .unwrap();
1013 set_segment_attributes(&mut span);
1014 assert_eq!(get_value!(span.is_segment!), &true);
1015 assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
1016 }
1017
1018 #[test]
1019 fn segment_overwrite_because_of_segment_id() {
1020 let mut span: Annotated<Span> = Annotated::from_json(
1021 r#"{
1022 "is_segment": false,
1023 "span_id": "fa90fdead5f74052",
1024 "segment_id": "fa90fdead5f74052",
1025 "parent_span_id": "fa90fdead5f74051"
1026 }"#,
1027 )
1028 .unwrap();
1029 set_segment_attributes(&mut span);
1030 assert_eq!(get_value!(span.is_segment!), &true);
1031 }
1032
1033 #[test]
1034 fn segment_overwrite_because_of_missing_parent() {
1035 let mut span: Annotated<Span> = Annotated::from_json(
1036 r#"{
1037 "is_segment": false,
1038 "span_id": "fa90fdead5f74052"
1039 }"#,
1040 )
1041 .unwrap();
1042 set_segment_attributes(&mut span);
1043 assert_eq!(get_value!(span.is_segment!), &true);
1044 assert_eq!(get_value!(span.segment_id!).to_string(), "fa90fdead5f74052");
1045 }
1046
1047 #[test]
1048 fn segment_no_parent_but_segment() {
1049 let mut span: Annotated<Span> = Annotated::from_json(
1050 r#"{
1051 "span_id": "fa90fdead5f74052",
1052 "segment_id": "ea90fdead5f74051"
1053 }"#,
1054 )
1055 .unwrap();
1056 set_segment_attributes(&mut span);
1057 assert_eq!(get_value!(span.is_segment!), &false);
1058 assert_eq!(get_value!(span.segment_id!).to_string(), "ea90fdead5f74051");
1059 }
1060
1061 #[test]
1062 fn segment_only_parent() {
1063 let mut span: Annotated<Span> = Annotated::from_json(
1064 r#"{
1065 "parent_span_id": "fa90fdead5f74051"
1066 }"#,
1067 )
1068 .unwrap();
1069 set_segment_attributes(&mut span);
1070 assert_eq!(get_value!(span.is_segment), None);
1071 assert_eq!(get_value!(span.segment_id), None);
1072 }
1073
1074 #[test]
1075 fn not_segment_but_inp_span() {
1076 let mut span: Annotated<Span> = Annotated::from_json(
1077 r#"{
1078 "op": "ui.interaction.click",
1079 "is_segment": false,
1080 "parent_span_id": "fa90fdead5f74051"
1081 }"#,
1082 )
1083 .unwrap();
1084 set_segment_attributes(&mut span);
1085 assert_eq!(get_value!(span.is_segment), None);
1086 assert_eq!(get_value!(span.segment_id), None);
1087 }
1088
1089 #[test]
1090 fn segment_but_inp_span() {
1091 let mut span: Annotated<Span> = Annotated::from_json(
1092 r#"{
1093 "op": "ui.interaction.click",
1094 "segment_id": "fa90fdead5f74051",
1095 "is_segment": true,
1096 "parent_span_id": "fa90fdead5f74051"
1097 }"#,
1098 )
1099 .unwrap();
1100 set_segment_attributes(&mut span);
1101 assert_eq!(get_value!(span.is_segment), None);
1102 assert_eq!(get_value!(span.segment_id), None);
1103 }
1104
1105 #[test]
1106 fn keep_browser_name() {
1107 let mut span: Annotated<Span> = Annotated::from_json(
1108 r#"{
1109 "data": {
1110 "browser.name": "foo"
1111 }
1112 }"#,
1113 )
1114 .unwrap();
1115 populate_ua_fields(
1116 span.value_mut().as_mut().unwrap(),
1117 None,
1118 ClientHints::default(),
1119 );
1120 assert_eq!(get_value!(span.data.browser_name!), "foo");
1121 }
1122
1123 #[test]
1124 fn keep_browser_name_when_ua_present() {
1125 let mut span: Annotated<Span> = Annotated::from_json(
1126 r#"{
1127 "data": {
1128 "browser.name": "foo",
1129 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1130 }
1131 }"#,
1132 )
1133 .unwrap();
1134 populate_ua_fields(
1135 span.value_mut().as_mut().unwrap(),
1136 None,
1137 ClientHints::default(),
1138 );
1139 assert_eq!(get_value!(span.data.browser_name!), "foo");
1140 }
1141
1142 #[test]
1143 fn derive_browser_name() {
1144 let mut span: Annotated<Span> = Annotated::from_json(
1145 r#"{
1146 "data": {
1147 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1148 }
1149 }"#,
1150 )
1151 .unwrap();
1152 populate_ua_fields(
1153 span.value_mut().as_mut().unwrap(),
1154 None,
1155 ClientHints::default(),
1156 );
1157 assert_eq!(
1158 get_value!(span.data.user_agent_original!),
1159 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1160 );
1161 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
1162 }
1163
1164 #[test]
1165 fn keep_user_agent_when_meta_is_present() {
1166 let mut span: Annotated<Span> = Annotated::from_json(
1167 r#"{
1168 "data": {
1169 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1170 }
1171 }"#,
1172 )
1173 .unwrap();
1174 populate_ua_fields(
1175 span.value_mut().as_mut().unwrap(),
1176 Some(
1177 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
1178 ),
1179 ClientHints::default(),
1180 );
1181 assert_eq!(
1182 get_value!(span.data.user_agent_original!),
1183 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1184 );
1185 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
1186 }
1187
1188 #[test]
1189 fn derive_user_agent() {
1190 let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
1191 populate_ua_fields(
1192 span.value_mut().as_mut().unwrap(),
1193 Some(
1194 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)",
1195 ),
1196 ClientHints::default(),
1197 );
1198 assert_eq!(
1199 get_value!(span.data.user_agent_original!),
1200 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ONS Internet Explorer 6.1; .NET CLR 1.1.4322)"
1201 );
1202 assert_eq!(get_value!(span.data.browser_name!), "IE");
1203 }
1204
1205 #[test]
1206 fn keep_user_agent_when_client_hints_are_present() {
1207 let mut span: Annotated<Span> = Annotated::from_json(
1208 r#"{
1209 "data": {
1210 "user_agent.original": "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1211 }
1212 }"#,
1213 )
1214 .unwrap();
1215 populate_ua_fields(
1216 span.value_mut().as_mut().unwrap(),
1217 None,
1218 ClientHints {
1219 sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
1220 ..Default::default()
1221 },
1222 );
1223 assert_eq!(
1224 get_value!(span.data.user_agent_original!),
1225 "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19"
1226 );
1227 assert_eq!(get_value!(span.data.browser_name!), "Chrome Mobile");
1228 }
1229
1230 #[test]
1231 fn derive_client_hints() {
1232 let mut span: Annotated<Span> = Annotated::from_json(r#"{}"#).unwrap();
1233 populate_ua_fields(
1234 span.value_mut().as_mut().unwrap(),
1235 None,
1236 ClientHints {
1237 sec_ch_ua: Some(r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#),
1238 ..Default::default()
1239 },
1240 );
1241 assert_eq!(get_value!(span.data.user_agent_original), None);
1242 assert_eq!(get_value!(span.data.browser_name!), "Opera");
1243 }
1244
1245 static GEO_LOOKUP: Lazy<GeoIpLookup> = Lazy::new(|| {
1246 GeoIpLookup::open("../relay-event-normalization/tests/fixtures/GeoIP2-Enterprise-Test.mmdb")
1247 .unwrap()
1248 });
1249
1250 fn normalize_config() -> NormalizeSpanConfig<'static> {
1251 NormalizeSpanConfig {
1252 received_at: DateTime::from_timestamp_nanos(0),
1253 timestamp_range: UnixTimestamp::from_datetime(
1254 DateTime::<Utc>::from_timestamp_millis(1000).unwrap(),
1255 )
1256 .unwrap()
1257 ..UnixTimestamp::from_datetime(DateTime::<Utc>::MAX_UTC).unwrap(),
1258 max_tag_value_size: 200,
1259 performance_score: None,
1260 measurements: None,
1261 ai_model_costs: None,
1262 max_name_and_unit_len: 200,
1263 tx_name_rules: &[],
1264 user_agent: None,
1265 client_hints: ClientHints::default(),
1266 allowed_hosts: &[],
1267 client_ip: Some(IpAddr("2.125.160.216".to_owned())),
1268 geo_lookup: Some(&GEO_LOOKUP),
1269 span_op_defaults: Default::default(),
1270 }
1271 }
1272
1273 #[test]
1274 fn user_ip_from_client_ip_without_auto() {
1275 let mut span = Annotated::from_json(
1276 r#"{
1277 "start_timestamp": 0,
1278 "timestamp": 1,
1279 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
1280 "span_id": "922dda2462ea4ac2",
1281 "data": {
1282 "client.address": "2.125.160.216"
1283 }
1284 }"#,
1285 )
1286 .unwrap();
1287
1288 normalize(&mut span, normalize_config()).unwrap();
1289
1290 assert_eq!(
1291 get_value!(span.data.client_address!).as_str(),
1292 "2.125.160.216"
1293 );
1294 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
1295 }
1296
1297 #[test]
1298 fn user_ip_from_client_ip_with_auto() {
1299 let mut span = Annotated::from_json(
1300 r#"{
1301 "start_timestamp": 0,
1302 "timestamp": 1,
1303 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
1304 "span_id": "922dda2462ea4ac2",
1305 "data": {
1306 "client.address": "{{auto}}"
1307 }
1308 }"#,
1309 )
1310 .unwrap();
1311
1312 normalize(&mut span, normalize_config()).unwrap();
1313
1314 assert_eq!(
1315 get_value!(span.data.client_address!).as_str(),
1316 "2.125.160.216"
1317 );
1318 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
1319 }
1320
1321 #[test]
1322 fn user_ip_from_client_ip_with_missing() {
1323 let mut span = Annotated::from_json(
1324 r#"{
1325 "start_timestamp": 0,
1326 "timestamp": 1,
1327 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
1328 "span_id": "922dda2462ea4ac2"
1329 }"#,
1330 )
1331 .unwrap();
1332
1333 normalize(&mut span, normalize_config()).unwrap();
1334
1335 assert_eq!(
1336 get_value!(span.data.client_address!).as_str(),
1337 "2.125.160.216"
1338 );
1339 assert_eq!(get_value!(span.data.user_geo_city!), "Boxford");
1340 }
1341
1342 #[test]
1343 fn exclusive_time_inside_span_data_i64() {
1344 let mut span = Annotated::from_json(
1345 r#"{
1346 "start_timestamp": 0,
1347 "timestamp": 1,
1348 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
1349 "span_id": "922dda2462ea4ac2",
1350 "data": {
1351 "sentry.exclusive_time": 123
1352 }
1353 }"#,
1354 )
1355 .unwrap();
1356
1357 normalize(&mut span, normalize_config()).unwrap();
1358
1359 let data = get_value!(span.data!);
1360 assert_eq!(data.exclusive_time, Annotated::empty());
1361 assert_eq!(*get_value!(span.exclusive_time!), 123.0);
1362 }
1363
1364 #[test]
1365 fn exclusive_time_inside_span_data_f64() {
1366 let mut span = Annotated::from_json(
1367 r#"{
1368 "start_timestamp": 0,
1369 "timestamp": 1,
1370 "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
1371 "span_id": "922dda2462ea4ac2",
1372 "data": {
1373 "sentry.exclusive_time": 123.0
1374 }
1375 }"#,
1376 )
1377 .unwrap();
1378
1379 normalize(&mut span, normalize_config()).unwrap();
1380
1381 let data = get_value!(span.data!);
1382 assert_eq!(data.exclusive_time, Annotated::empty());
1383 assert_eq!(*get_value!(span.exclusive_time!), 123.0);
1384 }
1385
1386 #[test]
1387 fn normalize_inp_spans() {
1388 let mut span = Annotated::from_json(
1389 r#"{
1390 "data": {
1391 "sentry.origin": "auto.http.browser.inp",
1392 "sentry.op": "ui.interaction.click",
1393 "release": "frontend@0735d75a05afe8d34bb0950f17c332eb32988862",
1394 "environment": "prod",
1395 "profile_id": "480ffcc911174ade9106b40ffbd822f5",
1396 "replay_id": "f39c5eb6539f4e49b9ad2b95226bc120",
1397 "transaction": "/replays",
1398 "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",
1399 "sentry.exclusive_time": 128.0
1400 },
1401 "description": "div.app-3diuwe.e88zkai6 > span.app-ksj0rb.e88zkai4",
1402 "op": "ui.interaction.click",
1403 "parent_span_id": "88457c3c28f4c0c6",
1404 "span_id": "be0e95480798a2a9",
1405 "start_timestamp": 1732635523.5048,
1406 "timestamp": 1732635523.6328,
1407 "trace_id": "bdaf4823d1c74068af238879e31e1be9",
1408 "origin": "auto.http.browser.inp",
1409 "exclusive_time": 128,
1410 "measurements": {
1411 "inp": {
1412 "value": 128,
1413 "unit": "millisecond"
1414 }
1415 },
1416 "segment_id": "88457c3c28f4c0c6"
1417 }"#,
1418 )
1419 .unwrap();
1420
1421 normalize(&mut span, normalize_config()).unwrap();
1422
1423 let data = get_value!(span.data!);
1424
1425 assert_eq!(data.exclusive_time, Annotated::empty());
1426 assert_eq!(*get_value!(span.exclusive_time!), 128.0);
1427
1428 assert_eq!(data.profile_id, Annotated::empty());
1429 assert_eq!(
1430 get_value!(span.profile_id!),
1431 &EventId("480ffcc911174ade9106b40ffbd822f5".parse().unwrap())
1432 );
1433 }
1434}