relay_server/services/processor/span/
processing.rs

1//! Contains the processing-only functionality.
2
3use 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    // We only implement trace-based sampling rules for now, which can be computed
66    // once for all spans in the envelope.
67    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            // Drop silently and not with an outcome, we only want to emit an outcome for the
190            // indexed category if the span was dropped by dynamic sampling.
191            // Dropping through the envelope will emit for both categories.
192            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        // Remove additional fields.
200        process_value(
201            &mut annotated_span,
202            &mut RemoveOtherProcessor,
203            ProcessingState::root(),
204        )
205        .ok();
206
207        // Validate for kafka (TODO: this should be moved to kafka producer)
208        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        // Write back:
233        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    // Only extract spans from transactions (not errors).
289    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        // If metrics extraction happened for the event, it also happened for its spans:
357        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    // Add child spans as envelope items.
378    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            // HACK: clone the span to set the segment_id. This should happen
384            // as part of normalization once standalone spans reach wider adoption.
385            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            // If a profile is associated with the transaction, also associate it with its
393            // child spans.
394            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
405/// Removes the transaction in case the project has made the transition to spans-only.
406pub 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/// Config needed to normalize a standalone span.
421#[derive(Clone, Debug)]
422struct NormalizeSpanConfig<'a> {
423    /// The time at which the event was received in this Relay.
424    received_at: DateTime<Utc>,
425    /// Allowed time range for spans.
426    timestamp_range: std::ops::Range<UnixTimestamp>,
427    /// The maximum allowed size of tag values in bytes. Longer values will be cropped.
428    max_tag_value_size: usize,
429    /// Configuration for generating performance score measurements for web vitals
430    performance_score: Option<&'a PerformanceScoreConfig>,
431    /// Configuration for measurement normalization in transaction events.
432    ///
433    /// Has an optional [`relay_event_normalization::MeasurementsConfig`] from both the project and the global level.
434    /// If at least one is provided, then normalization will truncate custom measurements
435    /// and add units of known built-in measurements.
436    measurements: Option<CombinedMeasurementsConfig<'a>>,
437    /// Configuration for AI model cost calculation
438    ai_model_costs: Option<&'a ModelCosts>,
439    /// Configuration to derive the `gen_ai.operation.type` field from other fields
440    ai_operation_type_map: Option<&'a AiOperationTypeMap>,
441    /// The maximum length for names of custom measurements.
442    ///
443    /// Measurements with longer names are removed from the transaction event and replaced with a
444    /// metadata entry.
445    max_name_and_unit_len: usize,
446    /// Transaction name normalization rules.
447    tx_name_rules: &'a [TransactionNameRule],
448    /// The user agent parsed from the request.
449    user_agent: Option<String>,
450    /// Client hints parsed from the request.
451    client_hints: ClientHints<String>,
452    /// Hosts that are not replaced by "*" in HTTP span grouping.
453    allowed_hosts: &'a [String],
454    /// The IP address of the SDK that sent the event.
455    ///
456    /// When `{{auto}}` is specified and there is no other IP address in the payload, such as in the
457    /// `request` context, this IP address gets added to `span.data.client_address`.
458    client_ip: Option<IpAddr>,
459    /// An initialized GeoIP lookup.
460    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    // Identify INP spans or other WebVital spans and make sure they are not wrapped in a segment.
509    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        // The span is a segment if and only if the segment_id matches the span_id.
524        span.is_segment = (segment_id == span_id).into();
525    } else if span.parent_span_id.is_empty() {
526        // If the span has no parent, it is automatically a segment:
527        span.is_segment = true.into();
528    }
529
530    // If the span is a segment, always set the segment_id to the current span_id:
531    if span.is_segment.value() == Some(&true) {
532        span.segment_id = span.span_id.clone();
533    }
534}
535
536/// Normalizes a standalone span.
537fn 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    // This follows the steps of `event::normalize`.
562
563    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(&timestamp_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    // Replace missing / {{auto}} IPs:
589    // Transaction and error events require an explicit `{{auto}}` to derive the IP, but
590    // for spans we derive it by default:
591    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    // Derive geo ip:
601    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    // Tag extraction:
641    let is_mobile = false; // TODO: find a way to determine is_mobile from a standalone span.
642    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        // User agent in span payload should take precendence over request
681        // client hints.
682        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
695/// Promotes some fields from span.data as there are predefined places for certain fields.
696fn promote_span_data_fields(span: &mut Span) {
697    // INP spans sets some top level span attributes inside span.data so make sure to pull
698    // them out to the top level before further processing.
699    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
740/// We do not extract or ingest spans with missing fields if those fields are required on the Kafka topic.
741fn 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); // force enable
953        let config = Arc::new(Config::default());
954        let (mut managed_envelope, event, _) = params(); // client sample rate is 0.2
955        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}