relay_server/services/processor/span/
processing.rs

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