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