Skip to main content

relay_event_normalization/
event.rs

1//! Event normalization.
2//!
3//! This module provides a function to normalize events.
4
5use std::collections::hash_map::DefaultHasher;
6
7use std::hash::{Hash, Hasher};
8use std::mem;
9use std::sync::OnceLock;
10
11use itertools::Itertools;
12use regex::Regex;
13use relay_base_schema::metrics::{
14    DurationUnit, FractionUnit, MetricUnit, can_be_valid_metric_name,
15};
16use relay_conventions::attributes::{
17    APP__VITALS__START__COLD__VALUE, APP__VITALS__START__SCREEN, APP__VITALS__START__TYPE,
18    APP__VITALS__START__VALUE, APP__VITALS__START__WARM__VALUE, SCORE__TOTAL,
19};
20use relay_conventions::interpolate;
21use relay_conventions::measurements::{
22    APP_START_COLD, APP_START_WARM, FRAMES_FROZEN, FRAMES_FROZEN_RATE, FRAMES_SLOW,
23    FRAMES_SLOW_RATE, FRAMES_TOTAL, STALL_PERCENTAGE,
24};
25use relay_event_schema::processor::{self, ProcessingAction, ProcessingState, Processor};
26use relay_event_schema::protocol::{
27    AsPair, Attributes, AutoInferSetting, ClientSdkInfo, Contexts, DebugImage, DeviceClass, Event,
28    EventId, EventType, Exception, Headers, IpAddr, Level, LogEntry, Measurement, Measurements,
29    PerformanceScoreContext, ReplayContext, Request, Span, SpanId, SpanV2, Tags, Timestamp,
30    TraceContext, TraceId, User, VALID_PLATFORMS,
31};
32use relay_protocol::{
33    Annotated, Empty, Error, ErrorKind, FiniteF64, FromValue, Getter, Meta, Object, Remark,
34    RemarkType, TryFromFloatError, Value,
35};
36use smallvec::SmallVec;
37use uuid::Uuid;
38
39use crate::normalize::request;
40use crate::span::ai::enrich_ai_event_data;
41use crate::span::tag_extraction::{extract_segment_name_from_event, extract_span_tags_from_event};
42use crate::utils::{self, MAX_DURATION_MOBILE_MS, get_event_user_tag};
43use crate::{
44    BorrowedSpanOpDefaults, BreakdownsConfig, CombinedMeasurementsConfig, EnrichedDsc, GeoIpLookup,
45    MaxChars, ModelMetadata, PerformanceScoreConfig, RawUserAgentInfo, SpanDescriptionRule,
46    TransactionNameConfig, breakdowns, event_error, legacy, mechanism, remove_other, schema, span,
47    stacktrace, transactions, trimming, user_agent,
48};
49
50/// Configuration for [`normalize_event`].
51#[derive(Clone, Debug)]
52pub struct NormalizationConfig<'a> {
53    /// The identifier of the target project, which gets added to the payload.
54    pub project_id: Option<u64>,
55
56    /// The name and version of the SDK that sent the event.
57    pub client: Option<String>,
58
59    /// The internal identifier of the DSN, which gets added to the payload.
60    ///
61    /// Note that this is different from the DSN's public key. The ID is usually numeric.
62    pub key_id: Option<String>,
63
64    /// The version of the protocol.
65    ///
66    /// This is a deprecated field, as there is no more versioning of Relay event payloads.
67    pub protocol_version: Option<String>,
68
69    /// Configuration for issue grouping.
70    ///
71    /// This configuration is persisted into the event payload to achieve idempotency in the
72    /// processing pipeline and for reprocessing.
73    pub grouping_config: Option<serde_json::Value>,
74
75    /// The IP address of the SDK that sent the event.
76    ///
77    /// When `{{auto}}` is specified and there is no other IP address in the payload, such as in the
78    /// `request` context, this IP address gets added to the `user` context.
79    pub client_ip: Option<&'a IpAddr>,
80
81    /// Specifies whether the client_ip should be used to determine the ip address of the user.
82    pub infer_ip_address: bool,
83
84    /// The SDK's sample rate as communicated via envelope headers.
85    ///
86    /// It is persisted into the event payload.
87    pub client_sample_rate: Option<f64>,
88
89    /// The user-agent and client hints obtained from the submission request headers.
90    ///
91    /// Client hints are the preferred way to infer device, operating system, and browser
92    /// information should the event payload contain no such data. If no client hints are present,
93    /// normalization falls back to the user agent.
94    pub user_agent: RawUserAgentInfo<&'a str>,
95
96    /// The maximum length for names of custom measurements.
97    ///
98    /// Measurements with longer names are removed from the transaction event and replaced with a
99    /// metadata entry.
100    pub max_name_and_unit_len: Option<usize>,
101
102    /// Configuration for measurement normalization in transaction events.
103    ///
104    /// Has an optional [`crate::MeasurementsConfig`] from both the project and the global level.
105    /// If at least one is provided, then normalization will truncate custom measurements
106    /// and add units of known built-in measurements.
107    pub measurements: Option<CombinedMeasurementsConfig<'a>>,
108
109    /// Emit breakdowns based on given configuration.
110    pub breakdowns_config: Option<&'a BreakdownsConfig>,
111
112    /// When `Some(true)`, context information is extracted from the user agent.
113    pub normalize_user_agent: Option<bool>,
114
115    /// Configuration to apply to transaction names, especially around sanitizing.
116    pub transaction_name_config: TransactionNameConfig<'a>,
117
118    /// When `true`, it is assumed that the event has been normalized before.
119    ///
120    /// This disables certain normalizations, especially all that are not idempotent. The
121    /// renormalize mode is intended for the use in the processing pipeline, so an event modified
122    /// during ingestion can be validated against the schema and large data can be trimmed. However,
123    /// advanced normalizations such as inferring contexts or clock drift correction are disabled.
124    pub is_renormalize: bool,
125
126    /// Overrides the default flag for other removal.
127    pub remove_other: bool,
128
129    /// When enabled, adds errors in the meta to the event's errors.
130    pub emit_event_errors: bool,
131
132    /// When `true`, extracts tags from event and spans and materializes them into `span.data`.
133    pub enrich_spans: bool,
134
135    /// The maximum allowed size of tag values in bytes. Longer values will be cropped.
136    pub max_tag_value_length: usize, // TODO: move span related fields into separate config.
137
138    /// Configuration for replacing identifiers in the span description with placeholders.
139    ///
140    /// This is similar to `transaction_name_config`, but applies to span descriptions.
141    pub span_description_rules: Option<&'a Vec<SpanDescriptionRule>>,
142
143    /// Configuration for generating performance score measurements for web vitals.
144    pub performance_score: Option<&'a PerformanceScoreConfig>,
145
146    /// Metadata for AI models including costs and context size.
147    pub ai_model_metadata: Option<&'a ModelMetadata>,
148
149    /// An initialized GeoIP lookup.
150    pub geoip_lookup: Option<&'a GeoIpLookup>,
151
152    /// When `Some(true)`, individual parts of the event payload is trimmed to a maximum size.
153    ///
154    /// See the event schema for size declarations.
155    pub enable_trimming: bool,
156
157    /// Controls whether spans should be normalized (e.g. normalizing the exclusive time).
158    ///
159    /// To normalize spans, `is_renormalize` must be disabled _and_ `normalize_spans` enabled.
160    pub normalize_spans: bool,
161
162    /// The identifier of the Replay running while this event was created.
163    ///
164    /// It is persisted into the event payload for correlation.
165    pub replay_id: Option<Uuid>,
166
167    /// Controls list of hosts to be excluded from scrubbing.
168    pub span_allowed_hosts: &'a [String],
169
170    /// Rules to infer `span.op` from other span fields.
171    pub span_op_defaults: BorrowedSpanOpDefaults<'a>,
172
173    /// Forces a valid trace context for error events.
174    ///
175    /// Sentry requires a valid trace context for events. This ensures a valid trace context always
176    /// exists.
177    ///
178    /// If the error does not contain a trace context, one will be created. If there is already an
179    /// existing trace context, it ensures it's valid and has a trace id.
180    ///
181    /// This is never applied to transaction events, which require a valid transaction context from
182    /// the SDK.
183    pub force_trace_context: bool,
184
185    /// Dynamic sampling context and additional attributes used for dsc span normalization.
186    pub dsc: Option<EnrichedDsc<'a>>,
187}
188
189impl Default for NormalizationConfig<'_> {
190    fn default() -> Self {
191        Self {
192            project_id: Default::default(),
193            client: Default::default(),
194            key_id: Default::default(),
195            protocol_version: Default::default(),
196            grouping_config: Default::default(),
197            client_ip: Default::default(),
198            infer_ip_address: true,
199            client_sample_rate: Default::default(),
200            user_agent: Default::default(),
201            max_name_and_unit_len: Default::default(),
202            breakdowns_config: Default::default(),
203            normalize_user_agent: Default::default(),
204            transaction_name_config: Default::default(),
205            is_renormalize: Default::default(),
206            remove_other: Default::default(),
207            emit_event_errors: Default::default(),
208            enrich_spans: Default::default(),
209            max_tag_value_length: usize::MAX,
210            span_description_rules: Default::default(),
211            performance_score: Default::default(),
212            geoip_lookup: Default::default(),
213            ai_model_metadata: Default::default(),
214            enable_trimming: false,
215            measurements: None,
216            normalize_spans: true,
217            replay_id: Default::default(),
218            span_allowed_hosts: Default::default(),
219            span_op_defaults: Default::default(),
220            force_trace_context: Default::default(),
221            dsc: None,
222        }
223    }
224}
225
226/// Normalizes an event.
227///
228/// Normalization consists of applying a series of transformations on the event
229/// payload based on the given configuration.
230pub fn normalize_event(event: &mut Annotated<Event>, config: &NormalizationConfig) {
231    let Annotated(Some(event), meta) = event else {
232        return;
233    };
234
235    let is_renormalize = config.is_renormalize;
236
237    // Convert legacy data structures to current format
238    let _ = legacy::LegacyProcessor.process_event(event, meta, ProcessingState::root());
239
240    if !is_renormalize {
241        // Check for required and non-empty values
242        let _ = schema::SchemaProcessor::new().process_event(event, meta, ProcessingState::root());
243
244        normalize(event, meta, config);
245    }
246
247    if config.enable_trimming {
248        // Trim large strings and databags down
249        let _ =
250            trimming::TrimmingProcessor::new().process_event(event, meta, ProcessingState::root());
251    }
252
253    if config.remove_other {
254        // Remove unknown attributes at every level
255        let _ =
256            remove_other::RemoveOtherProcessor.process_event(event, meta, ProcessingState::root());
257    }
258
259    if config.emit_event_errors {
260        // Add event errors for top-level keys
261        let _ =
262            event_error::EmitEventErrors::new().process_event(event, meta, ProcessingState::root());
263    }
264}
265
266/// Normalizes the given event based on the given config.
267fn normalize(event: &mut Event, meta: &mut Meta, config: &NormalizationConfig) {
268    // Normalize the transaction.
269    // (internally noops for non-transaction events).
270    // TODO: Parts of this processor should probably be a filter so we
271    // can revert some changes to ProcessingAction)
272    let mut transactions_processor = transactions::TransactionsProcessor::new(
273        config.transaction_name_config,
274        config.span_op_defaults,
275    );
276    let _ = transactions_processor.process_event(event, meta, ProcessingState::root());
277
278    let client_ip = config.client_ip.filter(|_| config.infer_ip_address);
279
280    // Process security reports first to ensure all props.
281    normalize_security_report(event, client_ip, &config.user_agent);
282
283    // Insert IP addrs before recursing, since geo lookup depends on it.
284    normalize_ip_addresses(
285        &mut event.request,
286        &mut event.user,
287        event.platform.as_str(),
288        client_ip,
289        event.client_sdk.value(),
290    );
291
292    if let Some(geoip_lookup) = config.geoip_lookup {
293        normalize_user_geoinfo(geoip_lookup, &mut event.user, config.client_ip);
294    }
295
296    // Validate the basic attributes we extract metrics from
297    let _ = processor::apply(&mut event.release, |release, meta| {
298        if crate::validate_release(release).is_ok() {
299            Ok(())
300        } else {
301            meta.add_error(ErrorKind::InvalidData);
302            Err(ProcessingAction::DeleteValueSoft)
303        }
304    });
305    let _ = processor::apply(&mut event.environment, |environment, meta| {
306        if crate::validate_environment(environment).is_ok() {
307            Ok(())
308        } else {
309            meta.add_error(ErrorKind::InvalidData);
310            Err(ProcessingAction::DeleteValueSoft)
311        }
312    });
313
314    // Default required attributes, even if they have errors
315    normalize_user(event);
316    normalize_logentry(&mut event.logentry, meta);
317    normalize_debug_meta(event);
318    normalize_breadcrumbs(event);
319    normalize_release_dist(event); // dist is a tag extracted along with other metrics from transactions
320    normalize_event_tags(event); // Tags are added to every metric
321
322    // TODO: Consider moving to store normalization
323    normalize_device_class(event);
324    normalize_stacktraces(event);
325    normalize_exceptions(event); // Browser extension filters look at the stacktrace
326    normalize_user_agent(event, config.normalize_user_agent); // Legacy browsers filter
327    normalize_event_measurements(event, config.measurements, config.max_name_and_unit_len); // Measurements are part of the metric extraction
328    backfill_app_vitals_start(event);
329    if let Some(version) = normalize_performance_score(event, config.performance_score) {
330        event
331            .contexts
332            .get_or_insert_with(Contexts::new)
333            .get_or_default::<PerformanceScoreContext>()
334            .score_profile_version = Annotated::new(version);
335    }
336    enrich_ai_event_data(event, config.ai_model_metadata);
337    normalize_breakdowns(event, config.breakdowns_config); // Breakdowns are part of the metric extraction too
338    normalize_default_attributes(event, meta, config);
339    normalize_trace_context_tags(event);
340    normalize_replay_context(event, config.replay_id);
341
342    let _ = processor::apply(&mut event.request, |request, _| {
343        request::normalize_request(request);
344        Ok(())
345    });
346
347    if config.force_trace_context && event.ty.value() != Some(&EventType::Transaction) {
348        normalize_force_trace_context(event);
349    }
350
351    // Some contexts need to be normalized before metrics extraction takes place.
352    normalize_contexts(&mut event.contexts);
353
354    if config.normalize_spans && event.ty.value() == Some(&EventType::Transaction) {
355        span::normalize_dsc_for_event_spans(event, config.dsc);
356        span::normalize_app_start_spans(event);
357        span::exclusive_time::compute_span_exclusive_time(event);
358    }
359
360    if config.enrich_spans {
361        extract_span_tags_from_event(
362            event,
363            config.max_tag_value_length,
364            config.span_allowed_hosts,
365        );
366        extract_segment_name_from_event(event);
367    }
368
369    if let Some(context) = event.context_mut::<TraceContext>() {
370        context.client_sample_rate = Annotated::from(config.client_sample_rate);
371    }
372}
373
374fn normalize_replay_context(event: &mut Event, replay_id: Option<Uuid>) {
375    if let Some(replay_id) = replay_id {
376        let contexts = event.contexts.get_or_insert_with(Contexts::default);
377        contexts.add(ReplayContext {
378            replay_id: Annotated::new(EventId(replay_id)),
379            other: Object::default(),
380        });
381    }
382}
383
384/// Backfills common security report attributes.
385fn normalize_security_report(
386    event: &mut Event,
387    client_ip: Option<&IpAddr>,
388    user_agent: &RawUserAgentInfo<&str>,
389) {
390    if !is_security_report(event) {
391        // This event is not a security report, exit here.
392        return;
393    }
394
395    event.logger.get_or_insert_with(|| "csp".to_owned());
396
397    if let Some(client_ip) = client_ip {
398        let user = event.user.value_mut().get_or_insert_with(User::default);
399        user.ip_address = Annotated::new(client_ip.to_owned());
400    }
401
402    if !user_agent.is_empty() {
403        let headers = event
404            .request
405            .value_mut()
406            .get_or_insert_with(Request::default)
407            .headers
408            .value_mut()
409            .get_or_insert_with(Headers::default);
410
411        user_agent.populate_event_headers(headers);
412    }
413}
414
415fn is_security_report(event: &Event) -> bool {
416    event.csp.value().is_some()
417        || event.expectct.value().is_some()
418        || event.expectstaple.value().is_some()
419        || event.hpkp.value().is_some()
420}
421
422/// Backfills IP addresses in various places.
423pub fn normalize_ip_addresses(
424    request: &mut Annotated<Request>,
425    user: &mut Annotated<User>,
426    platform: Option<&str>,
427    client_ip: Option<&IpAddr>,
428    client_sdk_settings: Option<&ClientSdkInfo>,
429) {
430    let infer_ip = client_sdk_settings
431        .and_then(|c| c.settings.0.as_ref())
432        .map(|s| s.infer_ip())
433        .unwrap_or_default();
434
435    // If infer_ip is set to Never then we just remove auto and don't continue
436    if let AutoInferSetting::Never = infer_ip {
437        // No user means there is also no IP so we can stop here
438        let Some(user) = user.value_mut() else {
439            return;
440        };
441        // If there is no IP we can also stop
442        let Some(ip) = user.ip_address.value() else {
443            return;
444        };
445        if ip.is_auto() {
446            user.ip_address.0 = None;
447            return;
448        }
449    }
450
451    let remote_addr_ip = request
452        .value()
453        .and_then(|r| r.env.value())
454        .and_then(|env| env.get("REMOTE_ADDR"))
455        .and_then(Annotated::<Value>::as_str)
456        .and_then(|ip| IpAddr::parse(ip).ok());
457
458    // IP address in REMOTE_ADDR will have precedence over client_ip because it's explicitly
459    // sent while client_ip is taken from X-Forwarded-For headers or the connection IP.
460    let inferred_ip = remote_addr_ip.as_ref().or(client_ip);
461
462    // We will infer IP addresses if:
463    // * The IP address is {{auto}}
464    // * the infer_ip setting is set to "auto"
465    let should_be_inferred = match user.value() {
466        Some(user) => match user.ip_address.value() {
467            Some(ip) => ip.is_auto(),
468            None => matches!(infer_ip, AutoInferSetting::Auto),
469        },
470        None => matches!(infer_ip, AutoInferSetting::Auto),
471    };
472
473    if should_be_inferred && let Some(ip) = inferred_ip {
474        let user = user.get_or_insert_with(User::default);
475        user.ip_address.set_value(Some(ip.to_owned()));
476    }
477
478    // Legacy behaviour:
479    // * Backfill if there is a REMOTE_ADDR and the user.ip_address was not backfilled until now
480    // * Empty means {{auto}} for some SDKs
481    if infer_ip == AutoInferSetting::Legacy {
482        if let Some(http_ip) = remote_addr_ip {
483            let user = user.get_or_insert_with(User::default);
484            user.ip_address.value_mut().get_or_insert(http_ip);
485        } else if let Some(client_ip) = inferred_ip {
486            let user = user.get_or_insert_with(User::default);
487            // auto is already handled above
488            if user.ip_address.value().is_none() {
489                // Only assume that empty means {{auto}} if there is no remark that the IP address has been removed.
490                let scrubbed_before = user
491                    .ip_address
492                    .meta()
493                    .iter_remarks()
494                    .any(|r| r.ty == RemarkType::Removed);
495                if !scrubbed_before {
496                    // In an ideal world all SDKs would set {{auto}} explicitly.
497                    if let Some("javascript") | Some("cocoa") | Some("objc") = platform {
498                        user.ip_address = Annotated::new(client_ip.to_owned());
499                    }
500                }
501            }
502        }
503    }
504}
505
506/// Sets the user's GeoIp info based on user's IP address.
507pub fn normalize_user_geoinfo(
508    geoip_lookup: &GeoIpLookup,
509    user: &mut Annotated<User>,
510    ip_addr: Option<&IpAddr>,
511) {
512    let user = user.value_mut().get_or_insert_with(User::default);
513    // The event was already populated with geo information so we don't have to do anything.
514    if user.geo.value().is_some() {
515        return;
516    }
517    if let Some(ip_address) = user
518        .ip_address
519        .value()
520        .filter(|ip| !ip.is_auto())
521        .or(ip_addr)
522        .and_then(|ip| ip.as_str().parse().ok())
523        && let Some(geo) = geoip_lookup.lookup(ip_address)
524    {
525        user.geo.set_value(Some(geo));
526    }
527}
528
529fn normalize_user(event: &mut Event) {
530    let Annotated(Some(user), _) = &mut event.user else {
531        return;
532    };
533
534    if !user.other.is_empty() {
535        let data = user.data.value_mut().get_or_insert_with(Object::new);
536        data.extend(std::mem::take(&mut user.other));
537    }
538
539    // We set the `sentry_user` field in the `Event` payload in order to have it ready for the extraction
540    // pipeline.
541    let event_user_tag = get_event_user_tag(user);
542    user.sentry_user.set_value(event_user_tag);
543}
544
545fn normalize_logentry(logentry: &mut Annotated<LogEntry>, _meta: &mut Meta) {
546    let _ = processor::apply(logentry, |logentry, meta| {
547        crate::logentry::normalize_logentry(logentry, meta)
548    });
549}
550
551/// Normalizes the debug images in the event's debug meta.
552fn normalize_debug_meta(event: &mut Event) {
553    let Annotated(Some(debug_meta), _) = &mut event.debug_meta else {
554        return;
555    };
556    let Annotated(Some(debug_images), _) = &mut debug_meta.images else {
557        return;
558    };
559
560    for annotated_image in debug_images {
561        let _ = processor::apply(annotated_image, |image, meta| match image {
562            DebugImage::Other(_) => {
563                meta.add_error(Error::invalid("unsupported debug image type"));
564                Err(ProcessingAction::DeleteValueSoft)
565            }
566            _ => Ok(()),
567        });
568    }
569}
570
571fn normalize_breadcrumbs(event: &mut Event) {
572    let Annotated(Some(breadcrumbs), _) = &mut event.breadcrumbs else {
573        return;
574    };
575    let Some(breadcrumbs) = breadcrumbs.values.value_mut() else {
576        return;
577    };
578
579    for annotated_breadcrumb in breadcrumbs {
580        let Annotated(Some(breadcrumb), _) = annotated_breadcrumb else {
581            continue;
582        };
583
584        if breadcrumb.ty.value().is_empty() {
585            breadcrumb.ty.set_value(Some("default".to_owned()));
586        }
587        if breadcrumb.level.value().is_none() {
588            breadcrumb.level.set_value(Some(Level::Info));
589        }
590    }
591}
592
593/// Ensures that the `release` and `dist` fields match up.
594fn normalize_release_dist(event: &mut Event) {
595    normalize_dist(&mut event.dist);
596}
597
598fn normalize_dist(distribution: &mut Annotated<String>) {
599    let _ = processor::apply(distribution, |dist, meta| {
600        let trimmed = dist.trim();
601        if trimmed.is_empty() {
602            return Err(ProcessingAction::DeleteValueHard);
603        } else if bytecount::num_chars(trimmed.as_bytes()) > MaxChars::Distribution.limit() {
604            meta.add_error(Error::new(ErrorKind::ValueTooLong));
605            return Err(ProcessingAction::DeleteValueSoft);
606        } else if trimmed != dist {
607            *dist = trimmed.to_owned();
608        }
609        Ok(())
610    });
611}
612
613struct DedupCache(SmallVec<[u64; 16]>);
614
615impl DedupCache {
616    pub fn new() -> Self {
617        Self(SmallVec::default())
618    }
619
620    pub fn probe<H: Hash>(&mut self, element: H) -> bool {
621        let mut hasher = DefaultHasher::new();
622        element.hash(&mut hasher);
623        let hash = hasher.finish();
624
625        if self.0.contains(&hash) {
626            false
627        } else {
628            self.0.push(hash);
629            true
630        }
631    }
632}
633
634/// Removes internal tags and adds tags for well-known attributes.
635fn normalize_event_tags(event: &mut Event) {
636    let tags = &mut event.tags.value_mut().get_or_insert_with(Tags::default).0;
637    let environment = &mut event.environment;
638    if environment.is_empty() {
639        *environment = Annotated::empty();
640    }
641
642    // Fix case where legacy apps pass environment as a tag instead of a top level key
643    if let Some(tag) = tags.remove("environment").and_then(Annotated::into_value) {
644        environment.get_or_insert_with(|| tag);
645    }
646
647    // Remove internal tags, that are generated with a `sentry:` prefix when saving the event.
648    // They are not allowed to be set by the client due to ambiguity. Also, deduplicate tags.
649    let mut tag_cache = DedupCache::new();
650    tags.retain(|entry| {
651        match entry.value() {
652            Some(tag) => match tag.key() {
653                Some("release") | Some("dist") | Some("user") | Some("filename")
654                | Some("function") => false,
655                name => tag_cache.probe(name),
656            },
657            // ToValue will decide if we should skip serializing Annotated::empty()
658            None => true,
659        }
660    });
661
662    for tag in tags.iter_mut() {
663        let _ = processor::apply(tag, |tag, _| {
664            if let Some(key) = tag.key()
665                && key.is_empty()
666            {
667                tag.0 = Annotated::from_error(Error::nonempty(), None);
668            }
669
670            if let Some(value) = tag.value()
671                && value.is_empty()
672            {
673                tag.1 = Annotated::from_error(Error::nonempty(), None);
674            }
675
676            Ok(())
677        });
678    }
679
680    let server_name = std::mem::take(&mut event.server_name);
681    if server_name.value().is_some() {
682        let tag_name = "server_name".to_owned();
683        tags.insert(tag_name, server_name);
684    }
685
686    let site = std::mem::take(&mut event.site);
687    if site.value().is_some() {
688        let tag_name = "site".to_owned();
689        tags.insert(tag_name, site);
690    }
691}
692
693// Reads device specs (family, memory, cpu, etc) from context and sets the device.class tag to high,
694// medium, or low.
695fn normalize_device_class(event: &mut Event) {
696    let tags = &mut event.tags.value_mut().get_or_insert_with(Tags::default).0;
697    let tag_name = "device.class".to_owned();
698    // Remove any existing device.class tag set by the client, since this should only be set by relay.
699    tags.remove("device.class");
700    if let Some(contexts) = event.contexts.value()
701        && let Some(device_class) = DeviceClass::from_contexts(contexts)
702    {
703        tags.insert(tag_name, Annotated::new(device_class.to_string()));
704    }
705}
706
707/// Normalizes all the stack traces in the given event.
708///
709/// Normalized stack traces are `event.stacktrace`, `event.exceptions.stacktrace`, and
710/// `event.thread.stacktrace`. Raw stack traces are not normalized.
711fn normalize_stacktraces(event: &mut Event) {
712    normalize_event_stacktrace(event);
713    normalize_exception_stacktraces(event);
714    normalize_thread_stacktraces(event);
715}
716
717/// Normalizes an event's stack trace, in `event.stacktrace`.
718fn normalize_event_stacktrace(event: &mut Event) {
719    let Annotated(Some(stacktrace), meta) = &mut event.stacktrace else {
720        return;
721    };
722    stacktrace::normalize_stacktrace(&mut stacktrace.0, meta);
723}
724
725/// Normalizes the stack traces in an event's exceptions, in `event.exceptions.stacktraces`.
726///
727/// Note: the raw stack traces, in `event.exceptions.raw_stacktraces` is not normalized.
728fn normalize_exception_stacktraces(event: &mut Event) {
729    let Some(event_exception) = event.exceptions.value_mut() else {
730        return;
731    };
732    let Some(exceptions) = event_exception.values.value_mut() else {
733        return;
734    };
735    for annotated_exception in exceptions {
736        let Some(exception) = annotated_exception.value_mut() else {
737            continue;
738        };
739        if let Annotated(Some(stacktrace), meta) = &mut exception.stacktrace {
740            stacktrace::normalize_stacktrace(&mut stacktrace.0, meta);
741        }
742    }
743}
744
745/// Normalizes the stack traces in an event's threads, in `event.threads.stacktraces`.
746///
747/// Note: the raw stack traces, in `event.threads.raw_stacktraces`, is not normalized.
748fn normalize_thread_stacktraces(event: &mut Event) {
749    let Some(event_threads) = event.threads.value_mut() else {
750        return;
751    };
752    let Some(threads) = event_threads.values.value_mut() else {
753        return;
754    };
755    for annotated_thread in threads {
756        let Some(thread) = annotated_thread.value_mut() else {
757            continue;
758        };
759        if let Annotated(Some(stacktrace), meta) = &mut thread.stacktrace {
760            stacktrace::normalize_stacktrace(&mut stacktrace.0, meta);
761        }
762    }
763}
764
765fn normalize_exceptions(event: &mut Event) {
766    let os_hint = mechanism::OsHint::from_event(event);
767
768    if let Some(exception_values) = event.exceptions.value_mut()
769        && let Some(exceptions) = exception_values.values.value_mut()
770    {
771        if exceptions.len() == 1
772            && event.stacktrace.value().is_some()
773            && let Some(exception) = exceptions.get_mut(0)
774            && let Some(exception) = exception.value_mut()
775        {
776            mem::swap(&mut exception.stacktrace, &mut event.stacktrace);
777            event.stacktrace = Annotated::empty();
778        }
779
780        // Exception mechanism needs SDK information to resolve proper names in
781        // exception meta (such as signal names). "SDK Information" really means
782        // the operating system version the event was generated on. Some
783        // normalization still works without sdk_info, such as mach_exception
784        // names (they can only occur on macOS).
785        //
786        // We also want to validate some other aspects of it.
787        for exception in exceptions {
788            normalize_exception(exception);
789            if let Some(exception) = exception.value_mut()
790                && let Some(mechanism) = exception.mechanism.value_mut()
791            {
792                mechanism::normalize_mechanism(mechanism, os_hint);
793            }
794        }
795    }
796}
797
798fn normalize_exception(exception: &mut Annotated<Exception>) {
799    static TYPE_VALUE_RE: OnceLock<Regex> = OnceLock::new();
800    let regex = TYPE_VALUE_RE.get_or_init(|| Regex::new(r"^(\w+):(.*)$").unwrap());
801
802    let _ = processor::apply(exception, |exception, meta| {
803        if exception.ty.value().is_empty()
804            && let Some(value_str) = exception.value.value_mut()
805        {
806            let new_values = regex
807                .captures(value_str)
808                .map(|cap| (cap[1].to_string(), cap[2].trim().to_owned().into()));
809
810            if let Some((new_type, new_value)) = new_values {
811                exception.ty.set_value(Some(new_type));
812                *value_str = new_value;
813            }
814        }
815
816        if exception.ty.value().is_empty() && exception.value.value().is_empty() {
817            meta.add_error(Error::with(ErrorKind::MissingAttribute, |error| {
818                error.insert("attribute", "type or value");
819            }));
820            return Err(ProcessingAction::DeleteValueSoft);
821        }
822
823        Ok(())
824    });
825}
826
827fn normalize_user_agent(_event: &mut Event, normalize_user_agent: Option<bool>) {
828    if normalize_user_agent.unwrap_or(false) {
829        user_agent::normalize_user_agent(_event);
830    }
831}
832
833/// Ensures measurements interface is only present for transaction events.
834fn normalize_event_measurements(
835    event: &mut Event,
836    measurements_config: Option<CombinedMeasurementsConfig>,
837    max_mri_len: Option<usize>,
838) {
839    if event.ty.value() != Some(&EventType::Transaction) {
840        // Only transaction events may have a measurements interface
841        event.measurements = Annotated::empty();
842    } else if let Annotated(Some(ref mut measurements), ref mut meta) = event.measurements {
843        normalize_measurements(
844            measurements,
845            meta,
846            measurements_config,
847            max_mri_len,
848            event.start_timestamp.0,
849            event.timestamp.0,
850        );
851    }
852}
853
854/// Ensure only valid measurements are ingested.
855pub fn normalize_measurements(
856    measurements: &mut Measurements,
857    meta: &mut Meta,
858    measurements_config: Option<CombinedMeasurementsConfig>,
859    max_mri_len: Option<usize>,
860    start_timestamp: Option<Timestamp>,
861    end_timestamp: Option<Timestamp>,
862) {
863    normalize_mobile_measurements(measurements);
864    normalize_units(measurements);
865
866    let duration_millis = start_timestamp.zip(end_timestamp).and_then(|(start, end)| {
867        FiniteF64::new(relay_common::time::chrono_to_positive_millis(end - start))
868    });
869
870    compute_measurements(duration_millis, measurements);
871    if let Some(measurements_config) = measurements_config {
872        remove_invalid_measurements(measurements, meta, measurements_config, max_mri_len);
873    }
874}
875
876/// Trait for containers that behave like a collection of [`Measurement`]s.
877///
878/// This exists to make [`normalize_performance_score`] work for both
879/// [`Measurements`] and [`Attributes`].
880pub trait MeasurementsLike {
881    /// Returns `true` if this collection contains the named measurement.
882    fn contains_measurement(&self, key: &str) -> bool;
883    /// Gets the value of the named measurement if this collection contains it.
884    fn get_measurement_value(&self, key: &str) -> Option<FiniteF64>;
885    /// Inserts a measurement into this collection.
886    fn insert_measurement(&mut self, key: String, value: Measurement);
887}
888
889impl MeasurementsLike for Measurements {
890    fn contains_measurement(&self, key: &str) -> bool {
891        self.contains_key(key)
892    }
893
894    fn get_measurement_value(&self, key: &str) -> Option<FiniteF64> {
895        self.get_value(key)
896    }
897
898    fn insert_measurement(&mut self, key: String, value: Measurement) {
899        self.insert(key, value.into());
900    }
901}
902
903impl MeasurementsLike for Attributes {
904    fn contains_measurement(&self, key: &str) -> bool {
905        self.0
906            .contains_key(relay_conventions::canonical(key).unwrap_or(key))
907    }
908
909    fn get_measurement_value(&self, key: &str) -> Option<FiniteF64> {
910        let value = self.get_value(relay_conventions::canonical(key).unwrap_or(key))?;
911        match value {
912            Value::F64(v) => FiniteF64::new(*v),
913            Value::U64(v) => FiniteF64::new(*v as f64),
914            Value::I64(v) => FiniteF64::new(*v as f64),
915            _ => None,
916        }
917    }
918
919    fn insert_measurement(&mut self, key: String, measurement: Measurement) {
920        self.0
921            .insert(key, measurement.value.map_value(|v| v.to_f64().into()));
922    }
923}
924
925/// Trait for types that provide mutable access to a collection of [`Measurement`]s.
926///
927/// This exists to make [`normalize_performance_score`] work for [`Event`]s,
928/// [`V1 Spans`](Span), and [`V2 Spans`](SpanV2).
929pub trait MutMeasurements {
930    type MeasurementsContainer: MeasurementsLike;
931    fn measurements(&mut self) -> &mut Annotated<Self::MeasurementsContainer>;
932}
933
934impl MutMeasurements for Event {
935    type MeasurementsContainer = Measurements;
936    fn measurements(&mut self) -> &mut Annotated<Self::MeasurementsContainer> {
937        &mut self.measurements
938    }
939}
940
941impl MutMeasurements for Span {
942    type MeasurementsContainer = Measurements;
943    fn measurements(&mut self) -> &mut Annotated<Self::MeasurementsContainer> {
944        &mut self.measurements
945    }
946}
947
948impl MutMeasurements for SpanV2 {
949    type MeasurementsContainer = Attributes;
950
951    fn measurements(&mut self) -> &mut Annotated<Self::MeasurementsContainer> {
952        &mut self.attributes
953    }
954}
955
956/// Computes performance score measurements for an event.
957///
958/// This computes score from vital measurements, using config options to define how it is
959/// calculated.
960pub fn normalize_performance_score(
961    event: &mut (impl Getter + MutMeasurements),
962    performance_score: Option<&PerformanceScoreConfig>,
963) -> Option<String> {
964    let mut version = None;
965    let Some(performance_score) = performance_score else {
966        return version;
967    };
968    for profile in &performance_score.profiles {
969        if let Some(condition) = &profile.condition {
970            if !condition.matches(event) {
971                continue;
972            }
973            if let Some(measurements) = event.measurements().value_mut() {
974                let mut should_add_total = false;
975                if profile.score_components.iter().any(|c| {
976                    !measurements.contains_measurement(c.measurement.as_str())
977                        && c.weight.abs() >= f64::EPSILON
978                        && !c.optional
979                }) {
980                    // All non-optional measurements with a profile weight greater than 0 are
981                    // required to exist on the event. Skip this profile if
982                    // a measurement with weight is missing.
983                    continue;
984                }
985                let mut score_total = FiniteF64::ZERO;
986                let mut weight_total = FiniteF64::ZERO;
987                for component in &profile.score_components {
988                    // Skip optional components if they are not present on the event.
989                    if component.optional
990                        && !measurements.contains_measurement(component.measurement.as_str())
991                    {
992                        continue;
993                    }
994                    weight_total += component.weight;
995                }
996                if weight_total.abs() < FiniteF64::EPSILON {
997                    // All components are optional or have a weight of `0`. We cannot compute
998                    // component weights, so we bail.
999                    continue;
1000                }
1001                for component in &profile.score_components {
1002                    // Optional measurements that are not present are given a weight of 0.
1003                    let mut normalized_component_weight = FiniteF64::ZERO;
1004
1005                    if let Some(value) =
1006                        measurements.get_measurement_value(component.measurement.as_str())
1007                    {
1008                        normalized_component_weight = component.weight.saturating_div(weight_total);
1009                        let cdf = utils::calculate_cdf_score(
1010                            value.to_f64().max(0.0), // Webvitals can't be negative, but we need to clamp in case of bad data.
1011                            component.p10.to_f64(),
1012                            component.p50.to_f64(),
1013                        );
1014
1015                        let cdf = Annotated::try_from(cdf);
1016
1017                        measurements.insert_measurement(
1018                            interpolate::score__ratio__key(&component.measurement),
1019                            Measurement {
1020                                value: cdf.clone(),
1021                                unit: (MetricUnit::Fraction(FractionUnit::Ratio)).into(),
1022                            },
1023                        );
1024
1025                        let component_score =
1026                            cdf.and_then(|cdf| match cdf * normalized_component_weight {
1027                                Some(v) => Annotated::new(v),
1028                                None => Annotated::from_error(TryFromFloatError, None),
1029                            });
1030
1031                        if let Some(component_score) = component_score.value() {
1032                            score_total += *component_score;
1033                            should_add_total = true;
1034                        }
1035
1036                        measurements.insert_measurement(
1037                            interpolate::score__key(&component.measurement),
1038                            Measurement {
1039                                value: component_score,
1040                                unit: (MetricUnit::Fraction(FractionUnit::Ratio)).into(),
1041                            },
1042                        );
1043                    }
1044
1045                    measurements.insert_measurement(
1046                        interpolate::score__weight__key(&component.measurement),
1047                        Measurement {
1048                            value: normalized_component_weight.into(),
1049                            unit: (MetricUnit::Fraction(FractionUnit::Ratio)).into(),
1050                        },
1051                    );
1052                }
1053                if should_add_total {
1054                    version.clone_from(&profile.version);
1055                    measurements.insert_measurement(
1056                        SCORE__TOTAL.to_owned(),
1057                        Measurement {
1058                            value: score_total.into(),
1059                            unit: (MetricUnit::Fraction(FractionUnit::Ratio)).into(),
1060                        },
1061                    );
1062                }
1063            }
1064            break; // Stop after the first matching profile.
1065        }
1066    }
1067    version
1068}
1069
1070// Extracts lcp related tags from the trace context.
1071fn normalize_trace_context_tags(event: &mut Event) {
1072    let tags = &mut event.tags.value_mut().get_or_insert_with(Tags::default).0;
1073    if let Some(contexts) = event.contexts.value()
1074        && let Some(trace_context) = contexts.get::<TraceContext>()
1075        && let Some(data) = trace_context.data.value()
1076    {
1077        if let Some(lcp_element) = data.lcp_element.value()
1078            && !tags.contains("lcp.element")
1079        {
1080            let tag_name = "lcp.element".to_owned();
1081            tags.insert(tag_name, Annotated::new(lcp_element.clone()));
1082        }
1083        if let Some(lcp_size) = data.lcp_size.value()
1084            && !tags.contains("lcp.size")
1085        {
1086            let tag_name = "lcp.size".to_owned();
1087            tags.insert(tag_name, Annotated::new(lcp_size.to_string()));
1088        }
1089        if let Some(lcp_id) = data.lcp_id.value() {
1090            let tag_name = "lcp.id".to_owned();
1091            if !tags.contains("lcp.id") {
1092                tags.insert(tag_name, Annotated::new(lcp_id.clone()));
1093            }
1094        }
1095        if let Some(lcp_url) = data.lcp_url.value() {
1096            let tag_name = "lcp.url".to_owned();
1097            if !tags.contains("lcp.url") {
1098                tags.insert(tag_name, Annotated::new(lcp_url.clone()));
1099            }
1100        }
1101    }
1102}
1103
1104/// Compute additional measurements derived from existing ones.
1105///
1106/// The added measurements are:
1107///
1108/// ```text
1109/// frames_slow_rate := measurements.frames_slow / measurements.frames_total
1110/// frames_frozen_rate := measurements.frames_frozen / measurements.frames_total
1111/// stall_percentage := measurements.stall_total_time / transaction.duration
1112/// ```
1113fn compute_measurements(
1114    transaction_duration_ms: Option<FiniteF64>,
1115    measurements: &mut Measurements,
1116) {
1117    if let Some(frames_total) = measurements.get_value(FRAMES_TOTAL)
1118        && frames_total > 0.0
1119    {
1120        if let Some(frames_frozen) = measurements.get_value(FRAMES_FROZEN) {
1121            let frames_frozen_rate = Measurement {
1122                value: (frames_frozen / frames_total).into(),
1123                unit: (MetricUnit::Fraction(FractionUnit::Ratio)).into(),
1124            };
1125            measurements.insert(FRAMES_FROZEN_RATE.to_owned(), frames_frozen_rate.into());
1126        }
1127        if let Some(frames_slow) = measurements.get_value(FRAMES_SLOW) {
1128            let frames_slow_rate = Measurement {
1129                value: (frames_slow / frames_total).into(),
1130                unit: MetricUnit::Fraction(FractionUnit::Ratio).into(),
1131            };
1132            measurements.insert(FRAMES_SLOW_RATE.to_owned(), frames_slow_rate.into());
1133        }
1134    }
1135
1136    // Get stall_percentage
1137    if let Some(transaction_duration_ms) = transaction_duration_ms
1138        && transaction_duration_ms > 0.0
1139        && let Some(stall_total_time) = measurements
1140            .get("stall_total_time")
1141            .and_then(Annotated::value)
1142        && matches!(
1143            stall_total_time.unit.value(),
1144            // Accept milliseconds or None, but not other units
1145            Some(&MetricUnit::Duration(DurationUnit::MilliSecond) | &MetricUnit::None) | None
1146        )
1147        && let Some(stall_total_time) = stall_total_time.value.0
1148    {
1149        let stall_percentage = Measurement {
1150            value: (stall_total_time / transaction_duration_ms).into(),
1151            unit: (MetricUnit::Fraction(FractionUnit::Ratio)).into(),
1152        };
1153        measurements.insert(STALL_PERCENTAGE.to_owned(), stall_percentage.into());
1154    }
1155}
1156
1157/// Emit any breakdowns
1158fn normalize_breakdowns(event: &mut Event, breakdowns_config: Option<&BreakdownsConfig>) {
1159    match breakdowns_config {
1160        None => {}
1161        Some(config) => breakdowns::normalize_breakdowns(event, config),
1162    }
1163}
1164
1165fn normalize_default_attributes(event: &mut Event, meta: &mut Meta, config: &NormalizationConfig) {
1166    let event_type = infer_event_type(event);
1167    event.ty = Annotated::from(event_type);
1168    event.project = Annotated::from(config.project_id);
1169    event.key_id = Annotated::from(config.key_id.clone());
1170    event.version = Annotated::from(config.protocol_version.clone());
1171    event.grouping_config = config
1172        .grouping_config
1173        .clone()
1174        .map_or(Annotated::empty(), |x| {
1175            FromValue::from_value(Annotated::<Value>::from(x))
1176        });
1177
1178    let _ = relay_event_schema::processor::apply(&mut event.platform, |platform, _| {
1179        if is_valid_platform(platform) {
1180            Ok(())
1181        } else {
1182            Err(ProcessingAction::DeleteValueSoft)
1183        }
1184    });
1185
1186    // Default required attributes, even if they have errors
1187    event.errors.get_or_insert_with(Vec::new);
1188    event.id.get_or_insert_with(EventId::new);
1189    event.platform.get_or_insert_with(|| "other".to_owned());
1190    event.logger.get_or_insert_with(String::new);
1191    event.extra.get_or_insert_with(Object::new);
1192    event.level.get_or_insert_with(|| match event_type {
1193        EventType::Transaction => Level::Info,
1194        _ => Level::Error,
1195    });
1196    if event.client_sdk.value().is_none() {
1197        event.client_sdk.set_value(get_sdk_info(config));
1198    }
1199
1200    if event.platform.as_str() == Some("java")
1201        && let Some(event_logger) = event.logger.value_mut().take()
1202    {
1203        let shortened = shorten_logger(event_logger, meta);
1204        event.logger.set_value(Some(shortened));
1205    }
1206}
1207
1208/// Returns `true` if the given platform string is a known platform identifier.
1209///
1210/// See [`VALID_PLATFORMS`] for a list of all known platforms.
1211pub fn is_valid_platform(platform: &str) -> bool {
1212    VALID_PLATFORMS.contains(&platform)
1213}
1214
1215/// Infers the `EventType` from the event's interfaces.
1216fn infer_event_type(event: &Event) -> EventType {
1217    // The event type may be set explicitly when constructing the event items from specific
1218    // items. This is DEPRECATED, and each distinct event type may get its own base class. For
1219    // the time being, this is only implemented for transactions, so be specific:
1220    if event.ty.value() == Some(&EventType::Transaction) {
1221        return EventType::Transaction;
1222    }
1223    if event.ty.value() == Some(&EventType::UserReportV2) {
1224        return EventType::UserReportV2;
1225    }
1226
1227    // The SDKs do not describe event types, and we must infer them from available attributes.
1228    let has_exceptions = event
1229        .exceptions
1230        .value()
1231        .and_then(|exceptions| exceptions.values.value())
1232        .filter(|values| !values.is_empty())
1233        .is_some();
1234
1235    if has_exceptions {
1236        EventType::Error
1237    } else if event.csp.value().is_some() {
1238        EventType::Csp
1239    } else if event.hpkp.value().is_some() {
1240        EventType::Hpkp
1241    } else if event.expectct.value().is_some() {
1242        EventType::ExpectCt
1243    } else if event.expectstaple.value().is_some() {
1244        EventType::ExpectStaple
1245    } else {
1246        EventType::Default
1247    }
1248}
1249
1250/// Returns the SDK info from the config.
1251fn get_sdk_info(config: &NormalizationConfig) -> Option<ClientSdkInfo> {
1252    config.client.as_ref().and_then(|client| {
1253        client
1254            .splitn(2, '/')
1255            .collect_tuple()
1256            .or_else(|| client.splitn(2, ' ').collect_tuple())
1257            .map(|(name, version)| ClientSdkInfo {
1258                name: Annotated::new(name.to_owned()),
1259                version: Annotated::new(version.to_owned()),
1260                ..Default::default()
1261            })
1262    })
1263}
1264
1265/// If the logger is longer than [`MaxChars::Logger`], it returns a String with
1266/// a shortened version of the logger. If not, the same logger is returned as a
1267/// String. The resulting logger is always trimmed.
1268///
1269/// To shorten the logger, all extra chars that don't fit into the maximum limit
1270/// are removed, from the beginning of the logger.  Then, if the remaining
1271/// substring contains a `.` somewhere but in the end, all chars until `.`
1272/// (exclusive) are removed.
1273///
1274/// Additionally, the new logger is prefixed with `*`, to indicate it was
1275/// shortened.
1276fn shorten_logger(logger: String, meta: &mut Meta) -> String {
1277    let original_len = bytecount::num_chars(logger.as_bytes());
1278    let trimmed = logger.trim();
1279    let logger_len = bytecount::num_chars(trimmed.as_bytes());
1280    if logger_len <= MaxChars::Logger.limit() {
1281        if trimmed == logger {
1282            return logger;
1283        } else {
1284            if trimmed.is_empty() {
1285                meta.add_remark(Remark {
1286                    ty: RemarkType::Removed,
1287                    rule_id: "@logger:remove".to_owned(),
1288                    range: Some((0, original_len)),
1289                });
1290            } else {
1291                meta.add_remark(Remark {
1292                    ty: RemarkType::Substituted,
1293                    rule_id: "@logger:trim".to_owned(),
1294                    range: None,
1295                });
1296            }
1297            meta.set_original_length(Some(original_len));
1298            return trimmed.to_owned();
1299        };
1300    }
1301
1302    let mut tokens = trimmed.split("").collect_vec();
1303    // Remove empty str tokens from the beginning and end.
1304    tokens.pop();
1305    tokens.reverse(); // Prioritize chars from the end of the string.
1306    tokens.pop();
1307
1308    let word_cut = remove_logger_extra_chars(&mut tokens);
1309    if word_cut {
1310        remove_logger_word(&mut tokens);
1311    }
1312
1313    tokens.reverse();
1314    meta.add_remark(Remark {
1315        ty: RemarkType::Substituted,
1316        rule_id: "@logger:replace".to_owned(),
1317        range: Some((0, logger_len - tokens.len())),
1318    });
1319    meta.set_original_length(Some(original_len));
1320
1321    format!("*{}", tokens.join(""))
1322}
1323
1324/// Remove as many tokens as needed to match the maximum char limit defined in
1325/// [`MaxChars::Logger`], and an extra token for the logger prefix. Returns
1326/// whether a word has been cut.
1327///
1328/// A word is considered any non-empty substring that doesn't contain a `.`.
1329fn remove_logger_extra_chars(tokens: &mut Vec<&str>) -> bool {
1330    // Leave one slot of space for the prefix
1331    let mut remove_chars = tokens.len() - MaxChars::Logger.limit() + 1;
1332    let mut word_cut = false;
1333    while remove_chars > 0 {
1334        if let Some(c) = tokens.pop() {
1335            if !word_cut && c != "." {
1336                word_cut = true;
1337            } else if word_cut && c == "." {
1338                word_cut = false;
1339            }
1340        }
1341        remove_chars -= 1;
1342    }
1343    word_cut
1344}
1345
1346/// If the `.` token is present, removes all tokens from the end of the vector
1347/// until `.`. If it isn't present, nothing is removed.
1348fn remove_logger_word(tokens: &mut Vec<&str>) {
1349    let mut delimiter_found = false;
1350    for token in tokens.iter() {
1351        if *token == "." {
1352            delimiter_found = true;
1353            break;
1354        }
1355    }
1356    if !delimiter_found {
1357        return;
1358    }
1359    while let Some(i) = tokens.last() {
1360        if *i == "." {
1361            break;
1362        }
1363        tokens.pop();
1364    }
1365}
1366
1367/// Creates a new trace context if it is missing and ensures the context has a valid trace and span id.
1368///
1369/// The function keeps existing meta on trace and span id intact, still surfacing user errors in the
1370/// original payload.
1371fn normalize_force_trace_context(event: &mut Event) {
1372    let contexts = event.contexts.get_or_insert_with(Contexts::new);
1373    let trace = contexts.get_or_default::<TraceContext>();
1374
1375    let trace_id = trace
1376        .trace_id
1377        .get_or_insert_with(|| TraceId::from(*event.id.get_or_insert_with(Default::default)));
1378    let _ = trace
1379        .span_id
1380        .get_or_insert_with(|| SpanId::derive_from_trace_id(trace_id));
1381}
1382
1383/// Normalizes incoming contexts for the downstream metric extraction.
1384fn normalize_contexts(contexts: &mut Annotated<Contexts>) {
1385    let _ = processor::apply(contexts, |contexts, _meta| {
1386        // Reprocessing context sent from SDKs must not be accepted, it is a Sentry-internal
1387        // construct.
1388        // [`normalize`] does not run on renormalization anyway.
1389        contexts.0.remove("reprocessing");
1390
1391        for annotated in &mut contexts.0.values_mut() {
1392            if let Some(context_inner) = annotated.value_mut() {
1393                crate::normalize::contexts::normalize_context(&mut context_inner.0);
1394            }
1395        }
1396
1397        Ok(())
1398    });
1399}
1400
1401/// New SDKs do not send measurements when they exceed 180 seconds.
1402///
1403/// Drop those outlier measurements for older SDKs.
1404fn filter_mobile_outliers(measurements: &mut Measurements) {
1405    for key in [
1406        APP_START_COLD,
1407        APP_START_WARM,
1408        // TODO: Regrettably, these measurements are not defined in conventions.
1409        "time_to_initial_display",
1410        "time_to_full_display",
1411    ] {
1412        if let Some(value) = measurements.get_value(key)
1413            && value > MAX_DURATION_MOBILE_MS
1414        {
1415            measurements.remove(key);
1416        }
1417    }
1418}
1419
1420fn normalize_mobile_measurements(measurements: &mut Measurements) {
1421    normalize_app_start_measurements(measurements);
1422    filter_mobile_outliers(measurements);
1423}
1424
1425const APP_START_SOURCES: [(&str, Option<&str>); 5] = [
1426    ("app_start_cold", Some("cold")),
1427    ("app_start_warm", Some("warm")),
1428    (APP__VITALS__START__VALUE, None),
1429    (APP__VITALS__START__COLD__VALUE, None),
1430    (APP__VITALS__START__WARM__VALUE, None),
1431];
1432
1433fn backfill_app_vitals_start(event: &mut Event) {
1434    if event.ty.value() != Some(&EventType::Transaction) {
1435        return;
1436    }
1437
1438    backfill_app_vitals_start_screen(event);
1439
1440    let already_set = event
1441        .tags
1442        .value()
1443        .is_some_and(|tags| tags.get(APP__VITALS__START__TYPE).is_some())
1444        || event
1445            .measurements
1446            .value()
1447            .is_some_and(|m| m.contains_key(APP__VITALS__START__VALUE));
1448    if already_set {
1449        return;
1450    }
1451
1452    let Some((start_type, value)) =
1453        APP_START_SOURCES
1454            .iter()
1455            .find_map(|(measurement_name, start_type)| {
1456                let start_type = (*start_type)?;
1457                let measurement = event
1458                    .measurements
1459                    .value()?
1460                    .get(*measurement_name)?
1461                    .value()?;
1462                if measurement.unit.value()
1463                    != Some(&MetricUnit::Duration(DurationUnit::MilliSecond))
1464                {
1465                    return None;
1466                }
1467
1468                let value = *measurement.value.value()?;
1469                Some((start_type, value))
1470            })
1471    else {
1472        return;
1473    };
1474
1475    event
1476        .measurements
1477        .get_or_insert_with(Default::default)
1478        .insert(
1479            APP__VITALS__START__VALUE.to_owned(),
1480            Annotated::new(Measurement {
1481                value: Annotated::new(value),
1482                unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
1483            }),
1484        );
1485
1486    event
1487        .tags
1488        .value_mut()
1489        .get_or_insert_with(Tags::default)
1490        .0
1491        .insert(
1492            String::from(APP__VITALS__START__TYPE),
1493            Annotated::new(start_type.to_owned()),
1494        );
1495}
1496
1497/// Backfills `app.vitals.start.screen` into root span data.
1498///
1499/// This runs from the transaction-only app-start backfill and writes only when:
1500/// - the transaction name is a concrete screen name;
1501/// - the trace op is `"ui.load"`;
1502/// - the event contains an app-start measurement;
1503/// - the SDK did not already provide `app.vitals.start.screen`.
1504fn backfill_app_vitals_start_screen(event: &mut Event) {
1505    let Some(screen) = event.transaction.value() else {
1506        return;
1507    };
1508    // TransactionsProcessor writes this placeholder for missing names before this backfill runs.
1509    if screen.is_empty() || screen == "<unlabeled transaction>" {
1510        return;
1511    }
1512
1513    let has_app_start_measurement = event.measurements.value().is_some_and(|measurements| {
1514        APP_START_SOURCES
1515            .iter()
1516            .any(|(measurement_name, _)| measurements.contains_key(*measurement_name))
1517    });
1518    if !has_app_start_measurement {
1519        return;
1520    }
1521
1522    let screen = screen.to_owned();
1523    let Some(trace_context) = event.context_mut::<TraceContext>() else {
1524        return;
1525    };
1526    if trace_context.op.as_str() != Some("ui.load")
1527        || trace_context
1528            .data
1529            .value()
1530            .is_some_and(|data| data.other.contains_key(APP__VITALS__START__SCREEN))
1531    {
1532        return;
1533    }
1534
1535    let data = trace_context.data.get_or_insert_with(Default::default);
1536    data.other.insert(
1537        APP__VITALS__START__SCREEN.to_owned(),
1538        Annotated::new(Value::String(screen)),
1539    );
1540}
1541
1542fn normalize_units(measurements: &mut Measurements) {
1543    for (name, measurement) in measurements.iter_mut() {
1544        let measurement = match measurement.value_mut() {
1545            Some(m) => m,
1546            None => continue,
1547        };
1548
1549        let stated_unit = measurement.unit.value().copied();
1550        let default_unit = get_metric_measurement_unit(name);
1551        measurement
1552            .unit
1553            .set_value(Some(stated_unit.or(default_unit).unwrap_or_default()))
1554    }
1555}
1556
1557/// Remove measurements that do not conform to the given config.
1558///
1559/// Built-in measurements are accepted if their unit is correct, dropped otherwise.
1560/// Custom measurements are accepted up to a limit.
1561///
1562/// Note that [`Measurements`] is a BTreeMap, which means its keys are sorted.
1563/// This ensures that for two events with the same measurement keys, the same set of custom
1564/// measurements is retained.
1565fn remove_invalid_measurements(
1566    measurements: &mut Measurements,
1567    meta: &mut Meta,
1568    measurements_config: CombinedMeasurementsConfig,
1569    max_name_and_unit_len: Option<usize>,
1570) {
1571    // If there is no project or global config allow all the custom measurements through.
1572    let max_custom_measurements = measurements_config
1573        .max_custom_measurements()
1574        .unwrap_or(usize::MAX);
1575
1576    let mut custom_measurements_count = 0;
1577    let mut removed_measurements = Object::new();
1578
1579    measurements.retain(|name, value| {
1580        let measurement = match value.value_mut() {
1581            Some(m) => m,
1582            None => return false,
1583        };
1584
1585        if !can_be_valid_metric_name(name) {
1586            meta.add_error(Error::invalid(format!(
1587                "Metric name contains invalid characters: \"{name}\""
1588            )));
1589            removed_measurements.insert(name.clone(), Annotated::new(std::mem::take(measurement)));
1590            return false;
1591        }
1592
1593        // TODO(jjbayer): Should we actually normalize the unit into the event?
1594        let unit = measurement.unit.value().unwrap_or(&MetricUnit::None);
1595
1596        if let Some(max_name_and_unit_len) = max_name_and_unit_len {
1597            let max_name_len = max_name_and_unit_len - unit.to_string().len();
1598
1599            if name.len() > max_name_len {
1600                meta.add_error(Error::invalid(format!(
1601                    "Metric name too long {}/{max_name_len}: \"{name}\"",
1602                    name.len(),
1603                )));
1604                removed_measurements
1605                    .insert(name.clone(), Annotated::new(std::mem::take(measurement)));
1606                return false;
1607            }
1608        }
1609
1610        // Check if this is a builtin measurement:
1611        if let Some(builtin_measurement) = measurements_config
1612            .builtin_measurement_keys()
1613            .find(|builtin| builtin.name() == name)
1614        {
1615            let value = measurement.value.value().unwrap_or(&FiniteF64::ZERO);
1616            // Drop negative values if the builtin measurement does not allow them.
1617            if !builtin_measurement.allow_negative() && *value < 0.0 {
1618                meta.add_error(Error::invalid(format!(
1619                    "Negative value for measurement {name} not allowed: {value}",
1620                )));
1621                removed_measurements
1622                    .insert(name.clone(), Annotated::new(std::mem::take(measurement)));
1623                return false;
1624            }
1625            // If the unit matches a built-in measurement, we allow it.
1626            // If the name matches but the unit is wrong, we do not even accept it as a custom measurement,
1627            // and just drop it instead.
1628            return builtin_measurement.unit() == unit;
1629        }
1630
1631        // For custom measurements, check the budget:
1632        if custom_measurements_count < max_custom_measurements {
1633            custom_measurements_count += 1;
1634            return true;
1635        }
1636
1637        meta.add_error(Error::invalid(format!("Too many measurements: {name}")));
1638        removed_measurements.insert(name.clone(), Annotated::new(std::mem::take(measurement)));
1639
1640        false
1641    });
1642
1643    if !removed_measurements.is_empty() {
1644        meta.set_original_value(Some(removed_measurements));
1645    }
1646}
1647
1648/// Returns the unit of the provided metric.
1649///
1650/// For known measurements, this returns `Some(MetricUnit)`, which can also include
1651/// `Some(MetricUnit::None)`. For unknown measurement names, this returns `None`.
1652fn get_metric_measurement_unit(measurement_name: &str) -> Option<MetricUnit> {
1653    // TODO: Might be neat to resolve this via conventions, but might also not
1654    // be worth the trouble.
1655    match measurement_name {
1656        // Web
1657        "fcp" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1658        "lcp" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1659        "fid" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1660        "fp" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1661        "inp" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1662        "ttfb" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1663        "ttfb.requesttime" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1664        "cls" => Some(MetricUnit::None),
1665
1666        // Mobile
1667        "app_start_cold" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1668        "app_start_warm" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1669        "frames_total" => Some(MetricUnit::None),
1670        "frames_slow" => Some(MetricUnit::None),
1671        "frames_slow_rate" => Some(MetricUnit::Fraction(FractionUnit::Ratio)),
1672        "frames_frozen" => Some(MetricUnit::None),
1673        "frames_frozen_rate" => Some(MetricUnit::Fraction(FractionUnit::Ratio)),
1674        "time_to_initial_display" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1675        "time_to_full_display" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1676
1677        // React-Native
1678        "stall_count" => Some(MetricUnit::None),
1679        "stall_total_time" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1680        "stall_longest_time" => Some(MetricUnit::Duration(DurationUnit::MilliSecond)),
1681        "stall_percentage" => Some(MetricUnit::Fraction(FractionUnit::Ratio)),
1682
1683        // Default
1684        _ => None,
1685    }
1686}
1687
1688/// Replaces dot.case app start measurements keys with snake_case keys.
1689///
1690/// The dot.case app start measurements keys are treated as custom measurements.
1691/// The snake_case is the key expected by the Sentry UI to aggregate and display in graphs.
1692fn normalize_app_start_measurements(measurements: &mut Measurements) {
1693    use relay_conventions::measurements::{APP_START_COLD, APP_START_WARM};
1694    if let Some(app_start_cold_value) = measurements.remove("app.start.cold") {
1695        measurements.insert(APP_START_COLD.to_owned(), app_start_cold_value);
1696    }
1697    if let Some(app_start_warm_value) = measurements.remove("app.start.warm") {
1698        measurements.insert(APP_START_WARM.to_owned(), app_start_warm_value);
1699    }
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704
1705    use relay_event_schema::protocol::SpanData;
1706    use relay_pattern::Pattern;
1707    use relay_protocol::assert_annotated_snapshot;
1708    use std::collections::BTreeMap;
1709    use std::collections::HashMap;
1710
1711    use insta::assert_debug_snapshot;
1712    use itertools::Itertools;
1713    use relay_event_schema::protocol::{Breadcrumb, Csp, DebugMeta, DeviceContext, Values};
1714    use relay_protocol::{SerializableAnnotated, get_value};
1715    use serde_json::json;
1716
1717    use super::*;
1718    use crate::eap;
1719    use crate::{ClientHints, MeasurementsConfig, ModelCostV2, ModelMetadataEntry};
1720
1721    const IOS_MOBILE_EVENT: &str = r#"
1722        {
1723            "sdk": {"name": "sentry.cocoa"},
1724            "contexts": {
1725                "trace": {
1726                    "op": "ui.load"
1727                }
1728            },
1729            "measurements": {
1730                "app_start_warm": {
1731                    "value": 8049.345970153808,
1732                    "unit": "millisecond"
1733                },
1734                "time_to_full_display": {
1735                    "value": 8240.571022033691,
1736                    "unit": "millisecond"
1737                },
1738                "time_to_initial_display": {
1739                    "value": 8049.345970153808,
1740                    "unit": "millisecond"
1741                }
1742            }
1743        }
1744        "#;
1745
1746    const ANDROID_MOBILE_EVENT: &str = r#"
1747        {
1748            "sdk": {"name": "sentry.java.android"},
1749            "contexts": {
1750                "trace": {
1751                    "op": "ui.load"
1752                }
1753            },
1754            "measurements": {
1755                "app_start_cold": {
1756                    "value": 22648,
1757                    "unit": "millisecond"
1758                },
1759                "time_to_full_display": {
1760                    "value": 22647,
1761                    "unit": "millisecond"
1762                },
1763                "time_to_initial_display": {
1764                    "value": 22647,
1765                    "unit": "millisecond"
1766                }
1767            }
1768        }
1769        "#;
1770
1771    fn collect_span_data<const N: usize>(event: Annotated<Event>) -> [Annotated<SpanData>; N] {
1772        get_value!(event.spans!)
1773            .iter()
1774            .map(|span| Annotated::new(get_value!(span.data!).clone()))
1775            .collect::<Vec<_>>()
1776            .try_into()
1777            .unwrap()
1778    }
1779
1780    fn trace_context_data(event: &Event) -> &Annotated<SpanData> {
1781        &event.context::<TraceContext>().unwrap().data
1782    }
1783
1784    fn app_vitals_start_screen_event(
1785        ty: &str,
1786        transaction: Option<&str>,
1787        trace_op: &str,
1788        measurement: Option<&str>,
1789        existing_screen: Option<&str>,
1790    ) -> Event {
1791        let mut payload = json!({
1792            "type": ty,
1793            "contexts": {"trace": {"op": trace_op}},
1794            "measurements": {},
1795        });
1796
1797        if let Some(transaction) = transaction {
1798            payload["transaction"] = json!(transaction);
1799        }
1800
1801        if let Some(measurement) = measurement {
1802            payload["measurements"] = json!({
1803                measurement: {"value": 1234.0, "unit": "millisecond"}
1804            });
1805        }
1806
1807        if let Some(screen) = existing_screen {
1808            payload["contexts"]["trace"]["data"] = json!({APP__VITALS__START__SCREEN: screen});
1809        }
1810
1811        Annotated::<Event>::from_json(&payload.to_string())
1812            .unwrap()
1813            .into_value()
1814            .unwrap()
1815    }
1816
1817    #[test]
1818    fn test_normalize_dist_none() {
1819        let mut dist = Annotated::default();
1820        normalize_dist(&mut dist);
1821        assert_eq!(dist.value(), None);
1822    }
1823
1824    #[test]
1825    fn test_normalize_dist_empty() {
1826        let mut dist = Annotated::new("".to_owned());
1827        normalize_dist(&mut dist);
1828        assert_eq!(dist.value(), None);
1829    }
1830
1831    #[test]
1832    fn test_normalize_dist_trim() {
1833        let mut dist = Annotated::new(" foo  ".to_owned());
1834        normalize_dist(&mut dist);
1835        assert_eq!(dist.value(), Some(&"foo".to_owned()));
1836    }
1837
1838    #[test]
1839    fn test_normalize_dist_whitespace() {
1840        let mut dist = Annotated::new(" ".to_owned());
1841        normalize_dist(&mut dist);
1842        assert_eq!(dist.value(), None);
1843    }
1844
1845    #[test]
1846    fn test_normalize_platform_and_level_with_transaction_event() {
1847        let json = r#"
1848        {
1849            "type": "transaction"
1850        }
1851        "#;
1852
1853        let Annotated(Some(mut event), mut meta) = Annotated::<Event>::from_json(json).unwrap()
1854        else {
1855            panic!("Invalid transaction json");
1856        };
1857
1858        normalize_default_attributes(&mut event, &mut meta, &NormalizationConfig::default());
1859
1860        assert_eq!(event.level.value().unwrap().to_string(), "info");
1861        assert_eq!(event.ty.value().unwrap().to_string(), "transaction");
1862        assert_eq!(event.platform.as_str().unwrap(), "other");
1863    }
1864
1865    #[test]
1866    fn test_normalize_platform_and_level_with_error_event() {
1867        let json = r#"
1868        {
1869            "type": "error",
1870            "exception": {
1871                "values": [{"type": "ValueError", "value": "Should not happen"}]
1872            }
1873        }
1874        "#;
1875
1876        let Annotated(Some(mut event), mut meta) = Annotated::<Event>::from_json(json).unwrap()
1877        else {
1878            panic!("Invalid error json");
1879        };
1880
1881        normalize_default_attributes(&mut event, &mut meta, &NormalizationConfig::default());
1882
1883        assert_eq!(event.level.value().unwrap().to_string(), "error");
1884        assert_eq!(event.ty.value().unwrap().to_string(), "error");
1885        assert_eq!(event.platform.value().unwrap().to_owned(), "other");
1886    }
1887
1888    #[test]
1889    fn test_computed_measurements() {
1890        let json = r#"
1891        {
1892            "type": "transaction",
1893            "timestamp": "2021-04-26T08:00:05+0100",
1894            "start_timestamp": "2021-04-26T08:00:00+0100",
1895            "measurements": {
1896                "frames_slow": {"value": 1},
1897                "frames_frozen": {"value": 2},
1898                "frames_total": {"value": 4},
1899                "stall_total_time": {"value": 4000, "unit": "millisecond"}
1900            }
1901        }
1902        "#;
1903
1904        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
1905
1906        normalize_event_measurements(&mut event, None, None);
1907
1908        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
1909        {
1910          "type": "transaction",
1911          "timestamp": 1619420405.0,
1912          "start_timestamp": 1619420400.0,
1913          "measurements": {
1914            "frames_frozen": {
1915              "value": 2.0,
1916              "unit": "none",
1917            },
1918            "frames_frozen_rate": {
1919              "value": 0.5,
1920              "unit": "ratio",
1921            },
1922            "frames_slow": {
1923              "value": 1.0,
1924              "unit": "none",
1925            },
1926            "frames_slow_rate": {
1927              "value": 0.25,
1928              "unit": "ratio",
1929            },
1930            "frames_total": {
1931              "value": 4.0,
1932              "unit": "none",
1933            },
1934            "stall_percentage": {
1935              "value": 0.8,
1936              "unit": "ratio",
1937            },
1938            "stall_total_time": {
1939              "value": 4000.0,
1940              "unit": "millisecond",
1941            },
1942          },
1943        }
1944        "###);
1945    }
1946
1947    #[test]
1948    fn test_filter_custom_measurements() {
1949        let json = r#"
1950        {
1951            "type": "transaction",
1952            "timestamp": "2021-04-26T08:00:05+0100",
1953            "start_timestamp": "2021-04-26T08:00:00+0100",
1954            "measurements": {
1955                "my_custom_measurement_1": {"value": 123},
1956                "frames_frozen": {"value": 666, "unit": "invalid_unit"},
1957                "frames_slow": {"value": 1},
1958                "my_custom_measurement_3": {"value": 456},
1959                "my_custom_measurement_2": {"value": 789}
1960            }
1961        }
1962        "#;
1963        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
1964
1965        let project_measurement_config: MeasurementsConfig = serde_json::from_value(json!({
1966            "builtinMeasurements": [
1967                {"name": "frames_frozen", "unit": "none"},
1968                {"name": "frames_slow", "unit": "none"}
1969            ],
1970            "maxCustomMeasurements": 2,
1971            "stray_key": "zzz"
1972        }))
1973        .unwrap();
1974
1975        let dynamic_measurement_config =
1976            CombinedMeasurementsConfig::new(Some(&project_measurement_config), None);
1977
1978        normalize_event_measurements(&mut event, Some(dynamic_measurement_config), None);
1979
1980        // Only two custom measurements are retained, in alphabetic order (1 and 2)
1981        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
1982        {
1983          "type": "transaction",
1984          "timestamp": 1619420405.0,
1985          "start_timestamp": 1619420400.0,
1986          "measurements": {
1987            "frames_slow": {
1988              "value": 1.0,
1989              "unit": "none",
1990            },
1991            "my_custom_measurement_1": {
1992              "value": 123.0,
1993              "unit": "none",
1994            },
1995            "my_custom_measurement_2": {
1996              "value": 789.0,
1997              "unit": "none",
1998            },
1999          },
2000          "_meta": {
2001            "measurements": {
2002              "": Meta(Some(MetaInner(
2003                err: [
2004                  [
2005                    "invalid_data",
2006                    {
2007                      "reason": "Too many measurements: my_custom_measurement_3",
2008                    },
2009                  ],
2010                ],
2011                val: Some({
2012                  "my_custom_measurement_3": {
2013                    "unit": "none",
2014                    "value": 456.0,
2015                  },
2016                }),
2017              ))),
2018            },
2019          },
2020        }
2021        "###);
2022    }
2023
2024    #[test]
2025    fn test_normalize_units() {
2026        let mut measurements = Annotated::<Measurements>::from_json(
2027            r#"{
2028                "fcp": {"value": 1.1},
2029                "stall_count": {"value": 3.3},
2030                "foo": {"value": 8.8}
2031            }"#,
2032        )
2033        .unwrap()
2034        .into_value()
2035        .unwrap();
2036        insta::assert_debug_snapshot!(measurements, @r###"
2037        Measurements(
2038            {
2039                "fcp": Measurement {
2040                    value: 1.1,
2041                    unit: ~,
2042                },
2043                "foo": Measurement {
2044                    value: 8.8,
2045                    unit: ~,
2046                },
2047                "stall_count": Measurement {
2048                    value: 3.3,
2049                    unit: ~,
2050                },
2051            },
2052        )
2053        "###);
2054        normalize_units(&mut measurements);
2055        insta::assert_debug_snapshot!(measurements, @r###"
2056        Measurements(
2057            {
2058                "fcp": Measurement {
2059                    value: 1.1,
2060                    unit: Duration(
2061                        MilliSecond,
2062                    ),
2063                },
2064                "foo": Measurement {
2065                    value: 8.8,
2066                    unit: None,
2067                },
2068                "stall_count": Measurement {
2069                    value: 3.3,
2070                    unit: None,
2071                },
2072            },
2073        )
2074        "###);
2075    }
2076
2077    #[test]
2078    fn test_normalize_security_report() {
2079        let mut event = Event {
2080            csp: Annotated::from(Csp::default()),
2081            ..Default::default()
2082        };
2083        let ipaddr = IpAddr("213.164.1.114".to_owned());
2084
2085        let client_ip = Some(&ipaddr);
2086
2087        let user_agent = RawUserAgentInfo {
2088            user_agent: Some(
2089                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0",
2090            ),
2091            client_hints: ClientHints {
2092                sec_ch_ua_platform: Some("macOS"),
2093                sec_ch_ua_platform_version: Some("13.2.0"),
2094                sec_ch_ua: Some(
2095                    r#""Chromium";v="110", "Not A(Brand";v="24", "Google Chrome";v="110""#,
2096                ),
2097                sec_ch_ua_model: Some("some model"),
2098            },
2099        };
2100
2101        // This call should fill the event headers with info from the user_agent which is
2102        // tested below.
2103        normalize_security_report(&mut event, client_ip, &user_agent);
2104
2105        let headers = event
2106            .request
2107            .value_mut()
2108            .get_or_insert_with(Request::default)
2109            .headers
2110            .value_mut()
2111            .get_or_insert_with(Headers::default);
2112
2113        assert_eq!(
2114            event.user.value().unwrap().ip_address,
2115            Annotated::from(ipaddr)
2116        );
2117        assert_eq!(
2118            headers.get_header(RawUserAgentInfo::USER_AGENT),
2119            user_agent.user_agent
2120        );
2121        assert_eq!(
2122            headers.get_header(ClientHints::SEC_CH_UA),
2123            user_agent.client_hints.sec_ch_ua,
2124        );
2125        assert_eq!(
2126            headers.get_header(ClientHints::SEC_CH_UA_MODEL),
2127            user_agent.client_hints.sec_ch_ua_model,
2128        );
2129        assert_eq!(
2130            headers.get_header(ClientHints::SEC_CH_UA_PLATFORM),
2131            user_agent.client_hints.sec_ch_ua_platform,
2132        );
2133        assert_eq!(
2134            headers.get_header(ClientHints::SEC_CH_UA_PLATFORM_VERSION),
2135            user_agent.client_hints.sec_ch_ua_platform_version,
2136        );
2137
2138        assert!(
2139            std::mem::size_of_val(&ClientHints::<&str>::default()) == 64,
2140            "If you add new fields, update the test accordingly"
2141        );
2142    }
2143
2144    #[test]
2145    fn test_no_device_class() {
2146        let mut event = Event {
2147            ..Default::default()
2148        };
2149        normalize_device_class(&mut event);
2150        let tags = &event.tags.value_mut().get_or_insert_with(Tags::default).0;
2151        assert_eq!(None, tags.get("device_class"));
2152    }
2153
2154    #[test]
2155    fn test_apple_low_device_class() {
2156        let mut event = Event {
2157            contexts: {
2158                let mut contexts = Contexts::new();
2159                contexts.add(DeviceContext {
2160                    family: "iPhone".to_owned().into(),
2161                    model: "iPhone8,4".to_owned().into(),
2162                    ..Default::default()
2163                });
2164                Annotated::new(contexts)
2165            },
2166            ..Default::default()
2167        };
2168        normalize_device_class(&mut event);
2169        assert_debug_snapshot!(event.tags, @r###"
2170        Tags(
2171            PairList(
2172                [
2173                    TagEntry(
2174                        "device.class",
2175                        "1",
2176                    ),
2177                ],
2178            ),
2179        )
2180        "###);
2181    }
2182
2183    #[test]
2184    fn test_apple_medium_device_class() {
2185        let mut event = Event {
2186            contexts: {
2187                let mut contexts = Contexts::new();
2188                contexts.add(DeviceContext {
2189                    family: "iPhone".to_owned().into(),
2190                    model: "iPhone12,8".to_owned().into(),
2191                    ..Default::default()
2192                });
2193                Annotated::new(contexts)
2194            },
2195            ..Default::default()
2196        };
2197        normalize_device_class(&mut event);
2198        assert_debug_snapshot!(event.tags, @r###"
2199        Tags(
2200            PairList(
2201                [
2202                    TagEntry(
2203                        "device.class",
2204                        "2",
2205                    ),
2206                ],
2207            ),
2208        )
2209        "###);
2210    }
2211
2212    #[test]
2213    fn test_android_low_device_class() {
2214        let mut event = Event {
2215            contexts: {
2216                let mut contexts = Contexts::new();
2217                contexts.add(DeviceContext {
2218                    family: "android".to_owned().into(),
2219                    processor_frequency: 1000.into(),
2220                    processor_count: 6.into(),
2221                    memory_size: (2 * 1024 * 1024 * 1024).into(),
2222                    ..Default::default()
2223                });
2224                Annotated::new(contexts)
2225            },
2226            ..Default::default()
2227        };
2228        normalize_device_class(&mut event);
2229        assert_debug_snapshot!(event.tags, @r###"
2230        Tags(
2231            PairList(
2232                [
2233                    TagEntry(
2234                        "device.class",
2235                        "1",
2236                    ),
2237                ],
2238            ),
2239        )
2240        "###);
2241    }
2242
2243    #[test]
2244    fn test_android_medium_device_class() {
2245        let mut event = Event {
2246            contexts: {
2247                let mut contexts = Contexts::new();
2248                contexts.add(DeviceContext {
2249                    family: "android".to_owned().into(),
2250                    processor_frequency: 2000.into(),
2251                    processor_count: 8.into(),
2252                    memory_size: (6 * 1024 * 1024 * 1024).into(),
2253                    ..Default::default()
2254                });
2255                Annotated::new(contexts)
2256            },
2257            ..Default::default()
2258        };
2259        normalize_device_class(&mut event);
2260        assert_debug_snapshot!(event.tags, @r###"
2261        Tags(
2262            PairList(
2263                [
2264                    TagEntry(
2265                        "device.class",
2266                        "2",
2267                    ),
2268                ],
2269            ),
2270        )
2271        "###);
2272    }
2273
2274    #[test]
2275    fn test_android_high_device_class() {
2276        let mut event = Event {
2277            contexts: {
2278                let mut contexts = Contexts::new();
2279                contexts.add(DeviceContext {
2280                    family: "android".to_owned().into(),
2281                    processor_frequency: 2500.into(),
2282                    processor_count: 8.into(),
2283                    memory_size: (6 * 1024 * 1024 * 1024).into(),
2284                    ..Default::default()
2285                });
2286                Annotated::new(contexts)
2287            },
2288            ..Default::default()
2289        };
2290        normalize_device_class(&mut event);
2291        assert_debug_snapshot!(event.tags, @r###"
2292        Tags(
2293            PairList(
2294                [
2295                    TagEntry(
2296                        "device.class",
2297                        "3",
2298                    ),
2299                ],
2300            ),
2301        )
2302        "###);
2303    }
2304
2305    #[test]
2306    fn test_keeps_valid_measurement() {
2307        let name = "lcp";
2308        let measurement = Measurement {
2309            value: Annotated::new(420.69.try_into().unwrap()),
2310            unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2311        };
2312
2313        assert!(!is_measurement_dropped(name, measurement));
2314    }
2315
2316    #[test]
2317    fn test_drops_too_long_measurement_names() {
2318        let name = "lcpppppppppppppppppppppppppppp";
2319        let measurement = Measurement {
2320            value: Annotated::new(420.69.try_into().unwrap()),
2321            unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2322        };
2323
2324        assert!(is_measurement_dropped(name, measurement));
2325    }
2326
2327    #[test]
2328    fn test_drops_measurements_with_invalid_characters() {
2329        let name = "i æm frøm nørwåy";
2330        let measurement = Measurement {
2331            value: Annotated::new(420.69.try_into().unwrap()),
2332            unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2333        };
2334
2335        assert!(is_measurement_dropped(name, measurement));
2336    }
2337
2338    fn is_measurement_dropped(name: &str, measurement: Measurement) -> bool {
2339        let max_name_and_unit_len = Some(30);
2340
2341        let mut measurements: BTreeMap<String, Annotated<Measurement>> = Object::new();
2342        measurements.insert(name.to_owned(), Annotated::new(measurement));
2343
2344        let mut measurements = Measurements(measurements);
2345        let mut meta = Meta::default();
2346        let measurements_config = MeasurementsConfig {
2347            max_custom_measurements: 1,
2348            ..Default::default()
2349        };
2350
2351        let dynamic_config = CombinedMeasurementsConfig::new(Some(&measurements_config), None);
2352
2353        // Just for clarity.
2354        // Checks that there is 1 measurement before processing.
2355        assert_eq!(measurements.len(), 1);
2356
2357        remove_invalid_measurements(
2358            &mut measurements,
2359            &mut meta,
2360            dynamic_config,
2361            max_name_and_unit_len,
2362        );
2363
2364        // Checks whether the measurement is dropped.
2365        measurements.is_empty()
2366    }
2367
2368    #[test]
2369    fn test_custom_measurements_not_dropped() {
2370        let mut measurements = Measurements(BTreeMap::from([(
2371            "custom_measurement".to_owned(),
2372            Annotated::new(Measurement {
2373                value: Annotated::new(42.0.try_into().unwrap()),
2374                unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2375            }),
2376        )]));
2377
2378        let original = measurements.clone();
2379        remove_invalid_measurements(
2380            &mut measurements,
2381            &mut Meta::default(),
2382            CombinedMeasurementsConfig::new(None, None),
2383            Some(30),
2384        );
2385
2386        assert_eq!(original, measurements);
2387    }
2388
2389    #[test]
2390    fn test_normalize_app_start_measurements_does_not_add_measurements() {
2391        let mut measurements = Annotated::<Measurements>::from_json(r###"{}"###)
2392            .unwrap()
2393            .into_value()
2394            .unwrap();
2395        insta::assert_debug_snapshot!(measurements, @r###"
2396        Measurements(
2397            {},
2398        )
2399        "###);
2400        normalize_app_start_measurements(&mut measurements);
2401        insta::assert_debug_snapshot!(measurements, @r###"
2402        Measurements(
2403            {},
2404        )
2405        "###);
2406    }
2407
2408    #[test]
2409    fn test_normalize_app_start_cold_measurements() {
2410        let mut measurements =
2411            Annotated::<Measurements>::from_json(r#"{"app.start.cold": {"value": 1.1}}"#)
2412                .unwrap()
2413                .into_value()
2414                .unwrap();
2415        insta::assert_debug_snapshot!(measurements, @r###"
2416        Measurements(
2417            {
2418                "app.start.cold": Measurement {
2419                    value: 1.1,
2420                    unit: ~,
2421                },
2422            },
2423        )
2424        "###);
2425        normalize_app_start_measurements(&mut measurements);
2426        insta::assert_debug_snapshot!(measurements, @r###"
2427        Measurements(
2428            {
2429                "app_start_cold": Measurement {
2430                    value: 1.1,
2431                    unit: ~,
2432                },
2433            },
2434        )
2435        "###);
2436    }
2437
2438    #[test]
2439    fn test_normalize_app_start_warm_measurements() {
2440        let mut measurements =
2441            Annotated::<Measurements>::from_json(r#"{"app.start.warm": {"value": 1.1}}"#)
2442                .unwrap()
2443                .into_value()
2444                .unwrap();
2445        insta::assert_debug_snapshot!(measurements, @r###"
2446        Measurements(
2447            {
2448                "app.start.warm": Measurement {
2449                    value: 1.1,
2450                    unit: ~,
2451                },
2452            },
2453        )
2454        "###);
2455        normalize_app_start_measurements(&mut measurements);
2456        insta::assert_debug_snapshot!(measurements, @r###"
2457        Measurements(
2458            {
2459                "app_start_warm": Measurement {
2460                    value: 1.1,
2461                    unit: ~,
2462                },
2463            },
2464        )
2465        "###);
2466    }
2467
2468    #[test]
2469    fn test_ai_legacy_measurements() {
2470        let json = r#"
2471            {
2472                "spans": [
2473                    {
2474                        "timestamp": 1702474613.0495,
2475                        "start_timestamp": 1702474613.0175,
2476                        "description": "OpenAI ",
2477                        "op": "ai.chat_completions.openai",
2478                        "span_id": "9c01bd820a083e63",
2479                        "parent_span_id": "a1e13f3f06239d69",
2480                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2481                        "measurements": {
2482                            "ai_prompt_tokens_used": {
2483                                "value": 1000
2484                            },
2485                            "ai_completion_tokens_used": {
2486                                "value": 2000
2487                            }
2488                        },
2489                        "data": {
2490                            "ai.model_id": "claude-2.1"
2491                        }
2492                    },
2493                    {
2494                        "timestamp": 1702474613.0495,
2495                        "start_timestamp": 1702474613.0175,
2496                        "description": "OpenAI ",
2497                        "op": "ai.chat_completions.openai",
2498                        "span_id": "ac01bd820a083e63",
2499                        "parent_span_id": "a1e13f3f06239d69",
2500                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2501                        "measurements": {
2502                            "ai_prompt_tokens_used": {
2503                                "value": 1000
2504                            },
2505                            "ai_completion_tokens_used": {
2506                                "value": 2000
2507                            }
2508                        },
2509                        "data": {
2510                            "ai.model_id": "gpt4-21-04"
2511                        }
2512                    }
2513                ]
2514            }
2515        "#;
2516
2517        let mut event = Annotated::<Event>::from_json(json).unwrap();
2518
2519        normalize_event(
2520            &mut event,
2521            &NormalizationConfig {
2522                ai_model_metadata: Some(&ModelMetadata {
2523                    version: 1,
2524                    models: HashMap::from([
2525                        (
2526                            Pattern::new("claude-2.1").unwrap(),
2527                            ModelMetadataEntry {
2528                                costs: Some(ModelCostV2 {
2529                                    input_per_token: 0.01,
2530                                    output_per_token: 0.02,
2531                                    output_reasoning_per_token: 0.03,
2532                                    input_cached_per_token: 0.0,
2533                                    input_cache_write_per_token: 0.0,
2534                                }),
2535                                context_size: None,
2536                            },
2537                        ),
2538                        (
2539                            Pattern::new("gpt4-21-04").unwrap(),
2540                            ModelMetadataEntry {
2541                                costs: Some(ModelCostV2 {
2542                                    input_per_token: 0.02,
2543                                    output_per_token: 0.03,
2544                                    output_reasoning_per_token: 0.04,
2545                                    input_cached_per_token: 0.0,
2546                                    input_cache_write_per_token: 0.0,
2547                                }),
2548                                context_size: None,
2549                            },
2550                        ),
2551                    ]),
2552                }),
2553                ..NormalizationConfig::default()
2554            },
2555        );
2556
2557        let [span1, span2] = collect_span_data(event);
2558
2559        assert_annotated_snapshot!(span1, @r#"
2560        {
2561          "gen_ai.usage.total_tokens": 3000.0,
2562          "gen_ai.usage.input_tokens": 1000.0,
2563          "gen_ai.usage.output_tokens": 2000.0,
2564          "gen_ai.response.model": "claude-2.1",
2565          "gen_ai.request.model": "claude-2.1",
2566          "gen_ai.cost.total_tokens": 50.0,
2567          "gen_ai.cost.input_tokens": 10.0,
2568          "gen_ai.cost.output_tokens": 40.0,
2569          "gen_ai.response.tokens_per_second": 62500.0,
2570          "gen_ai.operation.type": "ai_client"
2571        }
2572        "#);
2573        assert_annotated_snapshot!(span2, @r#"
2574        {
2575          "gen_ai.usage.total_tokens": 3000.0,
2576          "gen_ai.usage.input_tokens": 1000.0,
2577          "gen_ai.usage.output_tokens": 2000.0,
2578          "gen_ai.response.model": "gpt4-21-04",
2579          "gen_ai.request.model": "gpt4-21-04",
2580          "gen_ai.cost.total_tokens": 80.0,
2581          "gen_ai.cost.input_tokens": 20.0,
2582          "gen_ai.cost.output_tokens": 60.0,
2583          "gen_ai.response.tokens_per_second": 62500.0,
2584          "gen_ai.operation.type": "ai_client"
2585        }
2586        "#);
2587    }
2588
2589    #[test]
2590    fn test_ai_data() {
2591        let json = r#"
2592            {
2593                "spans": [
2594                    {
2595                        "timestamp": 1702474614.0175,
2596                        "start_timestamp": 1702474613.0175,
2597                        "description": "OpenAI ",
2598                        "op": "gen_ai.chat_completions.openai",
2599                        "span_id": "9c01bd820a083e63",
2600                        "parent_span_id": "a1e13f3f06239d69",
2601                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2602                        "data": {
2603                            "gen_ai.usage.input_tokens": 1000,
2604                            "gen_ai.usage.output_tokens": 2000,
2605                            "gen_ai.usage.output_tokens.reasoning": 1000,
2606                            "gen_ai.usage.input_tokens.cached": 500,
2607                            "gen_ai.request.model": "claude-2.1"
2608                        }
2609                    },
2610                    {
2611                        "timestamp": 1702474614.0175,
2612                        "start_timestamp": 1702474613.0175,
2613                        "description": "OpenAI ",
2614                        "op": "gen_ai.chat_completions.openai",
2615                        "span_id": "ac01bd820a083e63",
2616                        "parent_span_id": "a1e13f3f06239d69",
2617                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2618                        "data": {
2619                            "gen_ai.usage.input_tokens": 1000,
2620                            "gen_ai.usage.output_tokens": 2000,
2621                            "gen_ai.request.model": "gpt4-21-04"
2622                        }
2623                    },
2624                    {
2625                        "timestamp": 1702474614.0175,
2626                        "start_timestamp": 1702474613.0175,
2627                        "description": "OpenAI ",
2628                        "op": "gen_ai.chat_completions.openai",
2629                        "span_id": "ac01bd820a083e63",
2630                        "parent_span_id": "a1e13f3f06239d69",
2631                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2632                        "data": {
2633                            "gen_ai.usage.input_tokens": 1000,
2634                            "gen_ai.usage.output_tokens": 2000,
2635                            "gen_ai.response.model": "gpt4-21-04"
2636                        }
2637                    }
2638                ]
2639            }
2640        "#;
2641
2642        let mut event = Annotated::<Event>::from_json(json).unwrap();
2643
2644        normalize_event(
2645            &mut event,
2646            &NormalizationConfig {
2647                ai_model_metadata: Some(&ModelMetadata {
2648                    version: 1,
2649                    models: HashMap::from([
2650                        (
2651                            Pattern::new("claude-2.1").unwrap(),
2652                            ModelMetadataEntry {
2653                                costs: Some(ModelCostV2 {
2654                                    input_per_token: 0.01,
2655                                    output_per_token: 0.02,
2656                                    output_reasoning_per_token: 0.03,
2657                                    input_cached_per_token: 0.04,
2658                                    input_cache_write_per_token: 0.0,
2659                                }),
2660                                context_size: None,
2661                            },
2662                        ),
2663                        (
2664                            Pattern::new("gpt4-21-04").unwrap(),
2665                            ModelMetadataEntry {
2666                                costs: Some(ModelCostV2 {
2667                                    input_per_token: 0.09,
2668                                    output_per_token: 0.05,
2669                                    output_reasoning_per_token: 0.0,
2670                                    input_cached_per_token: 0.0,
2671                                    input_cache_write_per_token: 0.0,
2672                                }),
2673                                context_size: None,
2674                            },
2675                        ),
2676                    ]),
2677                }),
2678                ..NormalizationConfig::default()
2679            },
2680        );
2681
2682        let [span1, span2, span3] = collect_span_data(event);
2683
2684        assert_annotated_snapshot!(span1, @r#"
2685        {
2686          "gen_ai.usage.total_tokens": 3000.0,
2687          "gen_ai.usage.input_tokens": 1000,
2688          "gen_ai.usage.input_tokens.cached": 500,
2689          "gen_ai.usage.output_tokens": 2000,
2690          "gen_ai.usage.output_tokens.reasoning": 1000,
2691          "gen_ai.response.model": "claude-2.1",
2692          "gen_ai.request.model": "claude-2.1",
2693          "gen_ai.cost.total_tokens": 75.0,
2694          "gen_ai.cost.input_tokens": 25.0,
2695          "gen_ai.cost.output_tokens": 50.0,
2696          "gen_ai.response.tokens_per_second": 2000.0,
2697          "gen_ai.operation.type": "ai_client"
2698        }
2699        "#);
2700        assert_annotated_snapshot!(span2, @r#"
2701        {
2702          "gen_ai.usage.total_tokens": 3000.0,
2703          "gen_ai.usage.input_tokens": 1000,
2704          "gen_ai.usage.output_tokens": 2000,
2705          "gen_ai.response.model": "gpt4-21-04",
2706          "gen_ai.request.model": "gpt4-21-04",
2707          "gen_ai.cost.total_tokens": 190.0,
2708          "gen_ai.cost.input_tokens": 90.0,
2709          "gen_ai.cost.output_tokens": 100.0,
2710          "gen_ai.response.tokens_per_second": 2000.0,
2711          "gen_ai.operation.type": "ai_client"
2712        }
2713        "#);
2714        assert_annotated_snapshot!(span3, @r#"
2715        {
2716          "gen_ai.usage.total_tokens": 3000.0,
2717          "gen_ai.usage.input_tokens": 1000,
2718          "gen_ai.usage.output_tokens": 2000,
2719          "gen_ai.response.model": "gpt4-21-04",
2720          "gen_ai.cost.total_tokens": 190.0,
2721          "gen_ai.cost.input_tokens": 90.0,
2722          "gen_ai.cost.output_tokens": 100.0,
2723          "gen_ai.response.tokens_per_second": 2000.0,
2724          "gen_ai.operation.type": "ai_client"
2725        }
2726        "#);
2727    }
2728
2729    #[test]
2730    fn test_ai_data_with_no_tokens() {
2731        let json = r#"
2732            {
2733                "spans": [
2734                    {
2735                        "timestamp": 1702474613.0495,
2736                        "start_timestamp": 1702474613.0175,
2737                        "description": "OpenAI ",
2738                        "op": "gen_ai.invoke_agent",
2739                        "span_id": "9c01bd820a083e63",
2740                        "parent_span_id": "a1e13f3f06239d69",
2741                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2742                        "data": {
2743                            "gen_ai.request.model": "claude-2.1"
2744                        }
2745                    }
2746                ]
2747            }
2748        "#;
2749
2750        let mut event = Annotated::<Event>::from_json(json).unwrap();
2751
2752        normalize_event(
2753            &mut event,
2754            &NormalizationConfig {
2755                ai_model_metadata: Some(&ModelMetadata {
2756                    version: 1,
2757                    models: HashMap::from([(
2758                        Pattern::new("claude-2.1").unwrap(),
2759                        ModelMetadataEntry {
2760                            costs: Some(ModelCostV2 {
2761                                input_per_token: 0.01,
2762                                output_per_token: 0.02,
2763                                output_reasoning_per_token: 0.03,
2764                                input_cached_per_token: 0.0,
2765                                input_cache_write_per_token: 0.0,
2766                            }),
2767                            context_size: None,
2768                        },
2769                    )]),
2770                }),
2771                ..NormalizationConfig::default()
2772            },
2773        );
2774
2775        let [span] = collect_span_data(event);
2776
2777        assert_annotated_snapshot!(span, @r#"
2778        {
2779          "gen_ai.response.model": "claude-2.1",
2780          "gen_ai.request.model": "claude-2.1",
2781          "gen_ai.operation.type": "agent"
2782        }
2783        "#);
2784    }
2785
2786    #[test]
2787    fn test_ai_data_with_ai_op_prefix() {
2788        let json = r#"
2789            {
2790                "spans": [
2791                    {
2792                        "timestamp": 1702474613.0495,
2793                        "start_timestamp": 1702474613.0175,
2794                        "description": "OpenAI ",
2795                        "op": "ai.chat_completions.openai",
2796                        "span_id": "9c01bd820a083e63",
2797                        "parent_span_id": "a1e13f3f06239d69",
2798                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2799                        "data": {
2800                            "gen_ai.usage.input_tokens": 1000,
2801                            "gen_ai.usage.output_tokens": 2000,
2802                            "gen_ai.usage.output_tokens.reasoning": 1000,
2803                            "gen_ai.usage.input_tokens.cached": 500,
2804                            "gen_ai.request.model": "claude-2.1"
2805                        }
2806                    },
2807                    {
2808                        "timestamp": 1702474613.0495,
2809                        "start_timestamp": 1702474613.0175,
2810                        "description": "OpenAI ",
2811                        "op": "ai.chat_completions.openai",
2812                        "span_id": "ac01bd820a083e63",
2813                        "parent_span_id": "a1e13f3f06239d69",
2814                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2815                        "data": {
2816                            "gen_ai.usage.input_tokens": 1000,
2817                            "gen_ai.usage.output_tokens": 2000,
2818                            "gen_ai.request.model": "gpt4-21-04"
2819                        }
2820                    }
2821                ]
2822            }
2823        "#;
2824
2825        let mut event = Annotated::<Event>::from_json(json).unwrap();
2826
2827        normalize_event(
2828            &mut event,
2829            &NormalizationConfig {
2830                ai_model_metadata: Some(&ModelMetadata {
2831                    version: 1,
2832                    models: HashMap::from([
2833                        (
2834                            Pattern::new("claude-2.1").unwrap(),
2835                            ModelMetadataEntry {
2836                                costs: Some(ModelCostV2 {
2837                                    input_per_token: 0.01,
2838                                    output_per_token: 0.02,
2839                                    output_reasoning_per_token: 0.0,
2840                                    input_cached_per_token: 0.04,
2841                                    input_cache_write_per_token: 0.0,
2842                                }),
2843                                context_size: None,
2844                            },
2845                        ),
2846                        (
2847                            Pattern::new("gpt4-21-04").unwrap(),
2848                            ModelMetadataEntry {
2849                                costs: Some(ModelCostV2 {
2850                                    input_per_token: 0.09,
2851                                    output_per_token: 0.05,
2852                                    output_reasoning_per_token: 0.06,
2853                                    input_cached_per_token: 0.0,
2854                                    input_cache_write_per_token: 0.0,
2855                                }),
2856                                context_size: None,
2857                            },
2858                        ),
2859                    ]),
2860                }),
2861                ..NormalizationConfig::default()
2862            },
2863        );
2864
2865        let [span1, span2] = collect_span_data(event);
2866
2867        assert_annotated_snapshot!(span1, @r#"
2868        {
2869          "gen_ai.usage.total_tokens": 3000.0,
2870          "gen_ai.usage.input_tokens": 1000,
2871          "gen_ai.usage.input_tokens.cached": 500,
2872          "gen_ai.usage.output_tokens": 2000,
2873          "gen_ai.usage.output_tokens.reasoning": 1000,
2874          "gen_ai.response.model": "claude-2.1",
2875          "gen_ai.request.model": "claude-2.1",
2876          "gen_ai.cost.total_tokens": 65.0,
2877          "gen_ai.cost.input_tokens": 25.0,
2878          "gen_ai.cost.output_tokens": 40.0,
2879          "gen_ai.response.tokens_per_second": 62500.0,
2880          "gen_ai.operation.type": "ai_client"
2881        }
2882        "#);
2883        assert_annotated_snapshot!(span2, @r#"
2884        {
2885          "gen_ai.usage.total_tokens": 3000.0,
2886          "gen_ai.usage.input_tokens": 1000,
2887          "gen_ai.usage.output_tokens": 2000,
2888          "gen_ai.response.model": "gpt4-21-04",
2889          "gen_ai.request.model": "gpt4-21-04",
2890          "gen_ai.cost.total_tokens": 190.0,
2891          "gen_ai.cost.input_tokens": 90.0,
2892          "gen_ai.cost.output_tokens": 100.0,
2893          "gen_ai.response.tokens_per_second": 62500.0,
2894          "gen_ai.operation.type": "ai_client"
2895        }
2896        "#);
2897    }
2898
2899    #[test]
2900    fn test_ai_response_tokens_per_second_no_output_tokens() {
2901        let json = r#"
2902            {
2903                "spans": [
2904                    {
2905                        "timestamp": 1702474614.0175,
2906                        "start_timestamp": 1702474613.0175,
2907                        "op": "gen_ai.chat_completions",
2908                        "span_id": "9c01bd820a083e63",
2909                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2910                        "data": {
2911                            "gen_ai.usage.input_tokens": 500
2912                        }
2913                    }
2914                ]
2915            }
2916        "#;
2917
2918        let mut event = Annotated::<Event>::from_json(json).unwrap();
2919
2920        normalize_event(
2921            &mut event,
2922            &NormalizationConfig {
2923                ai_model_metadata: Some(&ModelMetadata {
2924                    version: 1,
2925                    models: HashMap::new(),
2926                }),
2927                ..NormalizationConfig::default()
2928            },
2929        );
2930
2931        let [span] = collect_span_data(event);
2932
2933        // Should not set response_tokens_per_second when there are no output tokens
2934        assert_annotated_snapshot!(span, @r#"
2935        {
2936          "gen_ai.usage.total_tokens": 500.0,
2937          "gen_ai.usage.input_tokens": 500,
2938          "gen_ai.operation.type": "ai_client"
2939        }
2940        "#);
2941    }
2942
2943    #[test]
2944    fn test_ai_response_tokens_per_second_zero_duration() {
2945        let json = r#"
2946            {
2947                "spans": [
2948                    {
2949                        "timestamp": 1702474613.0175,
2950                        "start_timestamp": 1702474613.0175,
2951                        "op": "gen_ai.chat_completions",
2952                        "span_id": "9c01bd820a083e63",
2953                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2954                        "data": {
2955                            "gen_ai.usage.output_tokens": 1000
2956                        }
2957                    }
2958                ]
2959            }
2960        "#;
2961
2962        let mut event = Annotated::<Event>::from_json(json).unwrap();
2963
2964        normalize_event(
2965            &mut event,
2966            &NormalizationConfig {
2967                ai_model_metadata: Some(&ModelMetadata {
2968                    version: 1,
2969                    models: HashMap::new(),
2970                }),
2971                ..NormalizationConfig::default()
2972            },
2973        );
2974
2975        let [span] = collect_span_data(event);
2976
2977        // Should not set response_tokens_per_second when duration is zero
2978        assert_annotated_snapshot!(span, @r#"
2979        {
2980          "gen_ai.usage.total_tokens": 1000.0,
2981          "gen_ai.usage.output_tokens": 1000,
2982          "gen_ai.operation.type": "ai_client"
2983        }
2984        "#);
2985    }
2986
2987    #[test]
2988    fn test_ai_operation_type_mapping() {
2989        let json = r#"
2990            {
2991                "type": "transaction",
2992                "transaction": "test-transaction",
2993                "spans": [
2994                    {
2995                        "op": "gen_ai.chat",
2996                        "description": "AI chat completion",
2997                        "data": {}
2998                    },
2999                    {
3000                        "op": "gen_ai.handoff",
3001                        "description": "AI agent handoff",
3002                        "data": {}
3003                    },
3004                    {
3005                        "op": "gen_ai.unknown",
3006                        "description": "Unknown AI operation",
3007                        "data": {}
3008                    }
3009                ]
3010            }
3011        "#;
3012
3013        let mut event = Annotated::<Event>::from_json(json).unwrap();
3014
3015        normalize_event(&mut event, &NormalizationConfig::default());
3016
3017        let [span1, span2, span3] = collect_span_data(event);
3018
3019        assert_annotated_snapshot!(span1, @r#"
3020        {
3021          "gen_ai.operation.type": "ai_client"
3022        }
3023        "#);
3024        assert_annotated_snapshot!(span2, @r#"
3025        {
3026          "gen_ai.operation.type": "handoff"
3027        }
3028        "#);
3029        assert_annotated_snapshot!(span3, @r#"
3030        {
3031          "gen_ai.operation.type": "ai_client"
3032        }
3033        "#);
3034    }
3035
3036    #[test]
3037    fn test_apple_high_device_class() {
3038        let mut event = Event {
3039            contexts: {
3040                let mut contexts = Contexts::new();
3041                contexts.add(DeviceContext {
3042                    family: "iPhone".to_owned().into(),
3043                    model: "iPhone15,3".to_owned().into(),
3044                    ..Default::default()
3045                });
3046                Annotated::new(contexts)
3047            },
3048            ..Default::default()
3049        };
3050        normalize_device_class(&mut event);
3051        assert_debug_snapshot!(event.tags, @r###"
3052        Tags(
3053            PairList(
3054                [
3055                    TagEntry(
3056                        "device.class",
3057                        "3",
3058                    ),
3059                ],
3060            ),
3061        )
3062        "###);
3063    }
3064
3065    #[test]
3066    fn test_filter_mobile_outliers() {
3067        let mut measurements =
3068            Annotated::<Measurements>::from_json(r#"{"app_start_warm": {"value": 180001}}"#)
3069                .unwrap()
3070                .into_value()
3071                .unwrap();
3072        assert_eq!(measurements.len(), 1);
3073        filter_mobile_outliers(&mut measurements);
3074        assert_eq!(measurements.len(), 0);
3075    }
3076
3077    #[test]
3078    fn test_backfill_app_vitals_start_cold() {
3079        let json = r#"{
3080            "type": "transaction",
3081            "timestamp": "2021-04-26T08:00:05+0100",
3082            "start_timestamp": "2021-04-26T08:00:00+0100",
3083            "measurements": {"app_start_cold": {"value": 1234.0, "unit": "millisecond"}}
3084        }"#;
3085        let mut event = Annotated::<Event>::from_json(json)
3086            .unwrap()
3087            .into_value()
3088            .unwrap();
3089        backfill_app_vitals_start(&mut event);
3090        assert_debug_snapshot!(event.measurements, @r#"
3091        Measurements(
3092            {
3093                "app.vitals.start.value": Measurement {
3094                    value: 1234.0,
3095                    unit: Duration(
3096                        MilliSecond,
3097                    ),
3098                },
3099                "app_start_cold": Measurement {
3100                    value: 1234.0,
3101                    unit: Duration(
3102                        MilliSecond,
3103                    ),
3104                },
3105            },
3106        )
3107        "#);
3108        assert_debug_snapshot!(event.tags, @r#"
3109        Tags(
3110            PairList(
3111                [
3112                    TagEntry(
3113                        "app.vitals.start.type",
3114                        "cold",
3115                    ),
3116                ],
3117            ),
3118        )
3119        "#);
3120    }
3121
3122    #[test]
3123    fn test_backfill_app_vitals_start_warm() {
3124        let json = r#"{
3125            "type": "transaction",
3126            "timestamp": "2021-04-26T08:00:05+0100",
3127            "start_timestamp": "2021-04-26T08:00:00+0100",
3128            "measurements": {"app_start_warm": {"value": 567.0, "unit": "millisecond"}}
3129        }"#;
3130        let mut event = Annotated::<Event>::from_json(json)
3131            .unwrap()
3132            .into_value()
3133            .unwrap();
3134        backfill_app_vitals_start(&mut event);
3135        assert_debug_snapshot!(event.measurements, @r#"
3136        Measurements(
3137            {
3138                "app.vitals.start.value": Measurement {
3139                    value: 567.0,
3140                    unit: Duration(
3141                        MilliSecond,
3142                    ),
3143                },
3144                "app_start_warm": Measurement {
3145                    value: 567.0,
3146                    unit: Duration(
3147                        MilliSecond,
3148                    ),
3149                },
3150            },
3151        )
3152        "#);
3153        assert_debug_snapshot!(event.tags, @r#"
3154        Tags(
3155            PairList(
3156                [
3157                    TagEntry(
3158                        "app.vitals.start.type",
3159                        "warm",
3160                    ),
3161                ],
3162            ),
3163        )
3164        "#);
3165    }
3166
3167    #[test]
3168    fn test_backfill_app_vitals_start_cold_preferred_over_warm() {
3169        let json = r#"{
3170            "type": "transaction",
3171            "timestamp": "2021-04-26T08:00:05+0100",
3172            "start_timestamp": "2021-04-26T08:00:00+0100",
3173            "measurements": {
3174                "app_start_cold": {"value": 100.0, "unit": "millisecond"},
3175                "app_start_warm": {"value": 200.0, "unit": "millisecond"}
3176            }
3177        }"#;
3178        let mut event = Annotated::<Event>::from_json(json)
3179            .unwrap()
3180            .into_value()
3181            .unwrap();
3182        backfill_app_vitals_start(&mut event);
3183        assert_debug_snapshot!(event.measurements, @r#"
3184        Measurements(
3185            {
3186                "app.vitals.start.value": Measurement {
3187                    value: 100.0,
3188                    unit: Duration(
3189                        MilliSecond,
3190                    ),
3191                },
3192                "app_start_cold": Measurement {
3193                    value: 100.0,
3194                    unit: Duration(
3195                        MilliSecond,
3196                    ),
3197                },
3198                "app_start_warm": Measurement {
3199                    value: 200.0,
3200                    unit: Duration(
3201                        MilliSecond,
3202                    ),
3203                },
3204            },
3205        )
3206        "#);
3207        assert_debug_snapshot!(event.tags, @r#"
3208        Tags(
3209            PairList(
3210                [
3211                    TagEntry(
3212                        "app.vitals.start.type",
3213                        "cold",
3214                    ),
3215                ],
3216            ),
3217        )
3218        "#);
3219    }
3220
3221    #[test]
3222    fn test_backfill_app_vitals_start_no_app_start_noop() {
3223        let json = r#"{
3224            "type": "transaction",
3225            "timestamp": "2021-04-26T08:00:05+0100",
3226            "start_timestamp": "2021-04-26T08:00:00+0100",
3227            "measurements": {"lcp": {"value": 100.0}}
3228        }"#;
3229        let mut event = Annotated::<Event>::from_json(json)
3230            .unwrap()
3231            .into_value()
3232            .unwrap();
3233        backfill_app_vitals_start(&mut event);
3234        assert_debug_snapshot!(event.measurements, @r#"
3235        Measurements(
3236            {
3237                "lcp": Measurement {
3238                    value: 100.0,
3239                    unit: ~,
3240                },
3241            },
3242        )
3243        "#);
3244        assert_debug_snapshot!(event.tags, @"~");
3245    }
3246
3247    #[test]
3248    fn test_backfill_app_vitals_start_respects_outlier_filter() {
3249        let json = r#"{
3250            "type": "transaction",
3251            "timestamp": "2021-04-26T08:00:05+0100",
3252            "start_timestamp": "2021-04-26T08:00:00+0100",
3253            "measurements": {"app_start_cold": {"value": 180001.0, "unit": "millisecond"}}
3254        }"#;
3255        let mut event = Annotated::<Event>::from_json(json)
3256            .unwrap()
3257            .into_value()
3258            .unwrap();
3259        normalize_event_measurements(&mut event, None, None);
3260        backfill_app_vitals_start(&mut event);
3261        assert_debug_snapshot!(event.measurements, @"
3262        Measurements(
3263            {},
3264        )
3265        ");
3266        assert_debug_snapshot!(event.tags, @"~");
3267    }
3268
3269    #[test]
3270    fn test_backfill_app_vitals_start_non_transaction_payload_noop() {
3271        let json = r#"{
3272            "type": "error",
3273            "measurements": {
3274                "app_start_cold": {"value": 1234.0, "unit": "millisecond"}
3275            }
3276        }"#;
3277        let mut event = Annotated::<Event>::from_json(json)
3278            .unwrap()
3279            .into_value()
3280            .unwrap();
3281        backfill_app_vitals_start(&mut event);
3282        assert_debug_snapshot!(event.measurements, @r#"
3283        Measurements(
3284            {
3285                "app_start_cold": Measurement {
3286                    value: 1234.0,
3287                    unit: Duration(
3288                        MilliSecond,
3289                    ),
3290                },
3291            },
3292        )
3293        "#);
3294        assert_debug_snapshot!(event.tags, @"~");
3295    }
3296
3297    #[test]
3298    fn test_backfill_app_vitals_start_does_not_overwrite_value() {
3299        let json = r#"{
3300            "type": "transaction",
3301            "timestamp": "2021-04-26T08:00:05+0100",
3302            "start_timestamp": "2021-04-26T08:00:00+0100",
3303            "measurements": {
3304                "app_start_cold": {"value": 100.0, "unit": "millisecond"},
3305                "app.vitals.start.value": {"value": 999.0, "unit": "millisecond"}
3306            }
3307        }"#;
3308        let mut event = Annotated::<Event>::from_json(json)
3309            .unwrap()
3310            .into_value()
3311            .unwrap();
3312        backfill_app_vitals_start(&mut event);
3313        assert_debug_snapshot!(event.measurements, @r#"
3314        Measurements(
3315            {
3316                "app.vitals.start.value": Measurement {
3317                    value: 999.0,
3318                    unit: Duration(
3319                        MilliSecond,
3320                    ),
3321                },
3322                "app_start_cold": Measurement {
3323                    value: 100.0,
3324                    unit: Duration(
3325                        MilliSecond,
3326                    ),
3327                },
3328            },
3329        )
3330        "#);
3331        assert_debug_snapshot!(event.tags, @"~");
3332    }
3333
3334    #[test]
3335    fn test_backfill_app_vitals_start_does_not_overwrite_type() {
3336        let json = r#"{
3337            "type": "transaction",
3338            "timestamp": "2021-04-26T08:00:05+0100",
3339            "start_timestamp": "2021-04-26T08:00:00+0100",
3340            "measurements": {"app_start_cold": {"value": 100.0, "unit": "millisecond"}}
3341        }"#;
3342        let mut event = Annotated::<Event>::from_json(json)
3343            .unwrap()
3344            .into_value()
3345            .unwrap();
3346        event
3347            .tags
3348            .value_mut()
3349            .get_or_insert_with(Tags::default)
3350            .0
3351            .insert(
3352                String::from(APP__VITALS__START__TYPE),
3353                Annotated::new("warm".to_owned()),
3354            );
3355
3356        backfill_app_vitals_start(&mut event);
3357
3358        assert_debug_snapshot!(event.measurements, @r#"
3359        Measurements(
3360            {
3361                "app_start_cold": Measurement {
3362                    value: 100.0,
3363                    unit: Duration(
3364                        MilliSecond,
3365                    ),
3366                },
3367            },
3368        )
3369        "#);
3370        assert_debug_snapshot!(event.tags, @r#"
3371        Tags(
3372            PairList(
3373                [
3374                    TagEntry(
3375                        "app.vitals.start.type",
3376                        "warm",
3377                    ),
3378                ],
3379            ),
3380        )
3381        "#);
3382    }
3383
3384    #[test]
3385    fn test_backfill_app_vitals_start_invalid_unit_noop() {
3386        let json = r#"{
3387            "type": "transaction",
3388            "timestamp": "2021-04-26T08:00:05+0100",
3389            "start_timestamp": "2021-04-26T08:00:00+0100",
3390            "measurements": {"app_start_cold": {"value": 1.5, "unit": "second"}}
3391        }"#;
3392        let mut event = Annotated::<Event>::from_json(json)
3393            .unwrap()
3394            .into_value()
3395            .unwrap();
3396        backfill_app_vitals_start(&mut event);
3397        assert_debug_snapshot!(event.measurements, @r#"
3398        Measurements(
3399            {
3400                "app_start_cold": Measurement {
3401                    value: 1.5,
3402                    unit: Duration(
3403                        Second,
3404                    ),
3405                },
3406            },
3407        )
3408        "#);
3409        assert_debug_snapshot!(event.tags, @"~");
3410    }
3411
3412    #[test]
3413    fn test_backfill_app_vitals_start_screen_from_legacy_measurement() {
3414        let json = r#"{
3415            "type": "transaction",
3416            "transaction": "MainActivity",
3417            "contexts": {
3418                "trace": {
3419                    "op": "ui.load"
3420                }
3421            },
3422            "measurements": {
3423                "app_start_cold": {
3424                    "value": 1234.0,
3425                    "unit": "millisecond"
3426                }
3427            }
3428        }"#;
3429        let mut event = Annotated::<Event>::from_json(json)
3430            .unwrap()
3431            .into_value()
3432            .unwrap();
3433
3434        backfill_app_vitals_start(&mut event);
3435
3436        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3437        {
3438          "app.vitals.start.screen": "MainActivity"
3439        }
3440        "#);
3441    }
3442
3443    #[test]
3444    fn test_backfill_app_vitals_start_screen_from_dotted_measurement() {
3445        let mut event = app_vitals_start_screen_event(
3446            "transaction",
3447            Some("SettingsActivity"),
3448            "ui.load",
3449            Some(APP__VITALS__START__WARM__VALUE),
3450            None,
3451        );
3452
3453        backfill_app_vitals_start(&mut event);
3454
3455        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3456        {
3457          "app.vitals.start.screen": "SettingsActivity"
3458        }
3459        "#);
3460    }
3461
3462    #[test]
3463    fn test_backfill_app_vitals_start_screen_from_start_value_measurement() {
3464        let mut event = app_vitals_start_screen_event(
3465            "transaction",
3466            Some("ProfileActivity"),
3467            "ui.load",
3468            Some(APP__VITALS__START__VALUE),
3469            None,
3470        );
3471
3472        backfill_app_vitals_start(&mut event);
3473
3474        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3475        {
3476          "app.vitals.start.screen": "ProfileActivity"
3477        }
3478        "#);
3479    }
3480
3481    #[test]
3482    fn test_backfill_app_vitals_start_screen_requires_ui_load() {
3483        let mut event = app_vitals_start_screen_event(
3484            "transaction",
3485            Some("MainActivity"),
3486            "navigation",
3487            Some("app_start_cold"),
3488            None,
3489        );
3490
3491        backfill_app_vitals_start(&mut event);
3492
3493        assert_annotated_snapshot!(trace_context_data(&event), @"{}");
3494    }
3495
3496    #[test]
3497    fn test_backfill_app_vitals_start_screen_requires_app_start_measurement() {
3498        let mut event = app_vitals_start_screen_event(
3499            "transaction",
3500            Some("MainActivity"),
3501            "ui.load",
3502            None,
3503            None,
3504        );
3505
3506        backfill_app_vitals_start(&mut event);
3507
3508        assert_annotated_snapshot!(trace_context_data(&event), @"{}");
3509    }
3510
3511    #[test]
3512    fn test_backfill_app_vitals_start_screen_only_requires_measurement_key() {
3513        let json = r#"{
3514            "type": "transaction",
3515            "transaction": "MainActivity",
3516            "contexts": {
3517                "trace": {
3518                    "op": "ui.load"
3519                }
3520            },
3521            "measurements": {
3522                "app_start_cold": {
3523                    "unit": "millisecond"
3524                }
3525            }
3526        }"#;
3527        let mut event = Annotated::<Event>::from_json(json)
3528            .unwrap()
3529            .into_value()
3530            .unwrap();
3531
3532        backfill_app_vitals_start(&mut event);
3533
3534        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3535        {
3536          "app.vitals.start.screen": "MainActivity"
3537        }
3538        "#);
3539    }
3540
3541    #[test]
3542    fn test_backfill_app_vitals_start_screen_preserves_existing_value() {
3543        let mut event = app_vitals_start_screen_event(
3544            "transaction",
3545            Some("MainActivity"),
3546            "ui.load",
3547            Some("app_start_cold"),
3548            Some("SDKScreen"),
3549        );
3550
3551        backfill_app_vitals_start(&mut event);
3552
3553        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3554        {
3555          "app.vitals.start.screen": "SDKScreen"
3556        }
3557        "#);
3558    }
3559
3560    #[test]
3561    fn test_backfill_app_vitals_start_screen_requires_transaction_name() {
3562        let mut event = app_vitals_start_screen_event(
3563            "transaction",
3564            Some("<unlabeled transaction>"),
3565            "ui.load",
3566            Some("app_start_cold"),
3567            None,
3568        );
3569
3570        backfill_app_vitals_start(&mut event);
3571
3572        assert_annotated_snapshot!(trace_context_data(&event), @"{}");
3573    }
3574
3575    #[test]
3576    fn test_computed_performance_score_transaction() {
3577        let json = r#"
3578        {
3579            "type": "transaction",
3580            "timestamp": "2021-04-26T08:00:05+0100",
3581            "start_timestamp": "2021-04-26T08:00:00+0100",
3582            "measurements": {
3583                "fid": {"value": 213, "unit": "millisecond"},
3584                "fcp": {"value": 1237, "unit": "millisecond"},
3585                "lcp": {"value": 6596, "unit": "millisecond"},
3586                "cls": {"value": 0.11}
3587            },
3588            "contexts": {
3589                "browser": {
3590                    "name": "Chrome",
3591                    "version": "120.1.1",
3592                    "type": "browser"
3593                }
3594            }
3595        }
3596        "#;
3597
3598        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3599
3600        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3601            "profiles": [
3602                {
3603                    "name": "Desktop",
3604                    "scoreComponents": [
3605                        {
3606                            "measurement": "fcp",
3607                            "weight": 0.15,
3608                            "p10": 900,
3609                            "p50": 1600
3610                        },
3611                        {
3612                            "measurement": "lcp",
3613                            "weight": 0.30,
3614                            "p10": 1200,
3615                            "p50": 2400
3616                        },
3617                        {
3618                            "measurement": "fid",
3619                            "weight": 0.30,
3620                            "p10": 100,
3621                            "p50": 300
3622                        },
3623                        {
3624                            "measurement": "cls",
3625                            "weight": 0.25,
3626                            "p10": 0.1,
3627                            "p50": 0.25
3628                        },
3629                        {
3630                            "measurement": "ttfb",
3631                            "weight": 0.0,
3632                            "p10": 0.2,
3633                            "p50": 0.4
3634                        },
3635                    ],
3636                    "condition": {
3637                        "op":"eq",
3638                        "name": "event.contexts.browser.name",
3639                        "value": "Chrome"
3640                    }
3641                }
3642            ]
3643        }))
3644        .unwrap();
3645
3646        normalize_performance_score(&mut event, Some(&performance_score));
3647
3648        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3649        {
3650          "type": "transaction",
3651          "timestamp": 1619420405.0,
3652          "start_timestamp": 1619420400.0,
3653          "contexts": {
3654            "browser": {
3655              "name": "Chrome",
3656              "version": "120.1.1",
3657              "type": "browser",
3658            },
3659          },
3660          "measurements": {
3661            "cls": {
3662              "value": 0.11,
3663            },
3664            "fcp": {
3665              "value": 1237.0,
3666              "unit": "millisecond",
3667            },
3668            "fid": {
3669              "value": 213.0,
3670              "unit": "millisecond",
3671            },
3672            "lcp": {
3673              "value": 6596.0,
3674              "unit": "millisecond",
3675            },
3676            "score.cls": {
3677              "value": 0.21864170607444863,
3678              "unit": "ratio",
3679            },
3680            "score.fcp": {
3681              "value": 0.10750855443790831,
3682              "unit": "ratio",
3683            },
3684            "score.fid": {
3685              "value": 0.19657361348282545,
3686              "unit": "ratio",
3687            },
3688            "score.lcp": {
3689              "value": 0.009238896571386584,
3690              "unit": "ratio",
3691            },
3692            "score.ratio.cls": {
3693              "value": 0.8745668242977945,
3694              "unit": "ratio",
3695            },
3696            "score.ratio.fcp": {
3697              "value": 0.7167236962527221,
3698              "unit": "ratio",
3699            },
3700            "score.ratio.fid": {
3701              "value": 0.6552453782760849,
3702              "unit": "ratio",
3703            },
3704            "score.ratio.lcp": {
3705              "value": 0.03079632190462195,
3706              "unit": "ratio",
3707            },
3708            "score.total": {
3709              "value": 0.531962770566569,
3710              "unit": "ratio",
3711            },
3712            "score.weight.cls": {
3713              "value": 0.25,
3714              "unit": "ratio",
3715            },
3716            "score.weight.fcp": {
3717              "value": 0.15,
3718              "unit": "ratio",
3719            },
3720            "score.weight.fid": {
3721              "value": 0.3,
3722              "unit": "ratio",
3723            },
3724            "score.weight.lcp": {
3725              "value": 0.3,
3726              "unit": "ratio",
3727            },
3728            "score.weight.ttfb": {
3729              "value": 0.0,
3730              "unit": "ratio",
3731            },
3732          },
3733        }
3734        "###);
3735    }
3736
3737    /// A version of `test_computed_performance_score_transaction` for
3738    /// V2 spans. Results are _mutatis mutandis_ the same.
3739    ///
3740    /// The `"condition"` on the profile is written as a disjunction,
3741    /// checking for the browser name in both `event.context` and in
3742    /// `span.attributes`.
3743    #[test]
3744    fn test_computed_performance_score_spanv2() {
3745        let json = r#"
3746        {
3747            "end_timestamp": "2021-04-26T08:00:05+0100",
3748            "start_timestamp": "2021-04-26T08:00:00+0100",
3749            "attributes": {
3750                "browser.name": {"value": "Chrome", "type": "string"},
3751                "browser.version": {"value": "120.1.1", "type": "string"},
3752                "fid": {"value": 213, "type": "double"},
3753                "browser.web_vital.fcp.value": {"value": 1237.0, "type": "double"},
3754                "lcp": {"value": 6596, "type": "double"},
3755                "browser.web_vital.cls.value": {"value": 0.11, "type": "double"}
3756            }
3757        }
3758        "#;
3759
3760        let mut span = Annotated::<SpanV2>::from_json(json).unwrap().0.unwrap();
3761
3762        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3763            "profiles": [
3764                {
3765                    "name": "Desktop",
3766                    "scoreComponents": [
3767                        {
3768                            "measurement": "fcp",
3769                            "weight": 0.15,
3770                            "p10": 900,
3771                            "p50": 1600
3772                        },
3773                        {
3774                            "measurement": "lcp",
3775                            "weight": 0.30,
3776                            "p10": 1200,
3777                            "p50": 2400
3778                        },
3779                        {
3780                            "measurement": "fid",
3781                            "weight": 0.30,
3782                            "p10": 100,
3783                            "p50": 300
3784                        },
3785                        {
3786                            "measurement": "cls",
3787                            "weight": 0.25,
3788                            "p10": 0.1,
3789                            "p50": 0.25
3790                        },
3791                        {
3792                            "measurement": "ttfb",
3793                            "weight": 0.0,
3794                            "p10": 0.2,
3795                            "p50": 0.4
3796                        },
3797                    ],
3798                    "condition": {
3799                        "op": "or",
3800                        "inner": [{
3801                            "op":"eq",
3802                            "name": "event.context.browser.name",
3803                            "value": "Chrome"
3804                        }, {
3805                            "op":"eq",
3806                            "name": "span.attributes.browser.name.value",
3807                            "value": "Chrome"
3808                        }]
3809                    }
3810                }
3811            ]
3812        }))
3813        .unwrap();
3814
3815        eap::normalize_attribute_names(&mut span.attributes);
3816        normalize_performance_score(&mut span, Some(&performance_score));
3817
3818        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(span)), {}, @r###"
3819        {
3820          "start_timestamp": 1619420400.0,
3821          "end_timestamp": 1619420405.0,
3822          "attributes": {
3823            "browser.name": {
3824              "type": "string",
3825              "value": "Chrome",
3826            },
3827            "browser.version": {
3828              "type": "string",
3829              "value": "120.1.1",
3830            },
3831            "browser.web_vital.cls.value": {
3832              "type": "double",
3833              "value": 0.11,
3834            },
3835            "browser.web_vital.fcp.value": {
3836              "type": "double",
3837              "value": 1237.0,
3838            },
3839            "browser.web_vital.lcp.value": {
3840              "type": "double",
3841              "value": 6596,
3842            },
3843            "fid": {
3844              "type": "double",
3845              "value": 213,
3846            },
3847            "lcp": {
3848              "type": "double",
3849              "value": 6596,
3850            },
3851            "score.cls": {
3852              "type": "double",
3853              "value": 0.21864170607444863,
3854            },
3855            "score.fcp": {
3856              "type": "double",
3857              "value": 0.10750855443790831,
3858            },
3859            "score.fid": {
3860              "type": "double",
3861              "value": 0.19657361348282545,
3862            },
3863            "score.lcp": {
3864              "type": "double",
3865              "value": 0.009238896571386584,
3866            },
3867            "score.ratio.cls": {
3868              "type": "double",
3869              "value": 0.8745668242977945,
3870            },
3871            "score.ratio.fcp": {
3872              "type": "double",
3873              "value": 0.7167236962527221,
3874            },
3875            "score.ratio.fid": {
3876              "type": "double",
3877              "value": 0.6552453782760849,
3878            },
3879            "score.ratio.lcp": {
3880              "type": "double",
3881              "value": 0.03079632190462195,
3882            },
3883            "score.total": {
3884              "type": "double",
3885              "value": 0.531962770566569,
3886            },
3887            "score.weight.cls": {
3888              "type": "double",
3889              "value": 0.25,
3890            },
3891            "score.weight.fcp": {
3892              "type": "double",
3893              "value": 0.15,
3894            },
3895            "score.weight.fid": {
3896              "type": "double",
3897              "value": 0.3,
3898            },
3899            "score.weight.lcp": {
3900              "type": "double",
3901              "value": 0.3,
3902            },
3903            "score.weight.ttfb": {
3904              "type": "double",
3905              "value": 0.0,
3906            },
3907          },
3908        }
3909        "###);
3910    }
3911
3912    // Test performance score is calculated correctly when the sum of weights is under 1.
3913    // The expected result should normalize the weights to a sum of 1 and scale the weight measurements accordingly.
3914    #[test]
3915    fn test_computed_performance_score_with_under_normalized_weights() {
3916        let json = r#"
3917        {
3918            "type": "transaction",
3919            "timestamp": "2021-04-26T08:00:05+0100",
3920            "start_timestamp": "2021-04-26T08:00:00+0100",
3921            "measurements": {
3922                "fid": {"value": 213, "unit": "millisecond"},
3923                "fcp": {"value": 1237, "unit": "millisecond"},
3924                "lcp": {"value": 6596, "unit": "millisecond"},
3925                "cls": {"value": 0.11}
3926            },
3927            "contexts": {
3928                "browser": {
3929                    "name": "Chrome",
3930                    "version": "120.1.1",
3931                    "type": "browser"
3932                }
3933            }
3934        }
3935        "#;
3936
3937        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3938
3939        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3940            "profiles": [
3941                {
3942                    "name": "Desktop",
3943                    "scoreComponents": [
3944                        {
3945                            "measurement": "fcp",
3946                            "weight": 0.03,
3947                            "p10": 900,
3948                            "p50": 1600
3949                        },
3950                        {
3951                            "measurement": "lcp",
3952                            "weight": 0.06,
3953                            "p10": 1200,
3954                            "p50": 2400
3955                        },
3956                        {
3957                            "measurement": "fid",
3958                            "weight": 0.06,
3959                            "p10": 100,
3960                            "p50": 300
3961                        },
3962                        {
3963                            "measurement": "cls",
3964                            "weight": 0.05,
3965                            "p10": 0.1,
3966                            "p50": 0.25
3967                        },
3968                        {
3969                            "measurement": "ttfb",
3970                            "weight": 0.0,
3971                            "p10": 0.2,
3972                            "p50": 0.4
3973                        },
3974                    ],
3975                    "condition": {
3976                        "op":"eq",
3977                        "name": "event.contexts.browser.name",
3978                        "value": "Chrome"
3979                    }
3980                }
3981            ]
3982        }))
3983        .unwrap();
3984
3985        normalize_performance_score(&mut event, Some(&performance_score));
3986
3987        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3988        {
3989          "type": "transaction",
3990          "timestamp": 1619420405.0,
3991          "start_timestamp": 1619420400.0,
3992          "contexts": {
3993            "browser": {
3994              "name": "Chrome",
3995              "version": "120.1.1",
3996              "type": "browser",
3997            },
3998          },
3999          "measurements": {
4000            "cls": {
4001              "value": 0.11,
4002            },
4003            "fcp": {
4004              "value": 1237.0,
4005              "unit": "millisecond",
4006            },
4007            "fid": {
4008              "value": 213.0,
4009              "unit": "millisecond",
4010            },
4011            "lcp": {
4012              "value": 6596.0,
4013              "unit": "millisecond",
4014            },
4015            "score.cls": {
4016              "value": 0.21864170607444863,
4017              "unit": "ratio",
4018            },
4019            "score.fcp": {
4020              "value": 0.10750855443790831,
4021              "unit": "ratio",
4022            },
4023            "score.fid": {
4024              "value": 0.19657361348282545,
4025              "unit": "ratio",
4026            },
4027            "score.lcp": {
4028              "value": 0.009238896571386584,
4029              "unit": "ratio",
4030            },
4031            "score.ratio.cls": {
4032              "value": 0.8745668242977945,
4033              "unit": "ratio",
4034            },
4035            "score.ratio.fcp": {
4036              "value": 0.7167236962527221,
4037              "unit": "ratio",
4038            },
4039            "score.ratio.fid": {
4040              "value": 0.6552453782760849,
4041              "unit": "ratio",
4042            },
4043            "score.ratio.lcp": {
4044              "value": 0.03079632190462195,
4045              "unit": "ratio",
4046            },
4047            "score.total": {
4048              "value": 0.531962770566569,
4049              "unit": "ratio",
4050            },
4051            "score.weight.cls": {
4052              "value": 0.25,
4053              "unit": "ratio",
4054            },
4055            "score.weight.fcp": {
4056              "value": 0.15,
4057              "unit": "ratio",
4058            },
4059            "score.weight.fid": {
4060              "value": 0.3,
4061              "unit": "ratio",
4062            },
4063            "score.weight.lcp": {
4064              "value": 0.3,
4065              "unit": "ratio",
4066            },
4067            "score.weight.ttfb": {
4068              "value": 0.0,
4069              "unit": "ratio",
4070            },
4071          },
4072        }
4073        "###);
4074    }
4075
4076    // Test performance score is calculated correctly when the sum of weights is over 1.
4077    // The expected result should normalize the weights to a sum of 1 and scale the weight measurements accordingly.
4078    #[test]
4079    fn test_computed_performance_score_with_over_normalized_weights() {
4080        let json = r#"
4081        {
4082            "type": "transaction",
4083            "timestamp": "2021-04-26T08:00:05+0100",
4084            "start_timestamp": "2021-04-26T08:00:00+0100",
4085            "measurements": {
4086                "fid": {"value": 213, "unit": "millisecond"},
4087                "fcp": {"value": 1237, "unit": "millisecond"},
4088                "lcp": {"value": 6596, "unit": "millisecond"},
4089                "cls": {"value": 0.11}
4090            },
4091            "contexts": {
4092                "browser": {
4093                    "name": "Chrome",
4094                    "version": "120.1.1",
4095                    "type": "browser"
4096                }
4097            }
4098        }
4099        "#;
4100
4101        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4102
4103        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4104            "profiles": [
4105                {
4106                    "name": "Desktop",
4107                    "scoreComponents": [
4108                        {
4109                            "measurement": "fcp",
4110                            "weight": 0.30,
4111                            "p10": 900,
4112                            "p50": 1600
4113                        },
4114                        {
4115                            "measurement": "lcp",
4116                            "weight": 0.60,
4117                            "p10": 1200,
4118                            "p50": 2400
4119                        },
4120                        {
4121                            "measurement": "fid",
4122                            "weight": 0.60,
4123                            "p10": 100,
4124                            "p50": 300
4125                        },
4126                        {
4127                            "measurement": "cls",
4128                            "weight": 0.50,
4129                            "p10": 0.1,
4130                            "p50": 0.25
4131                        },
4132                        {
4133                            "measurement": "ttfb",
4134                            "weight": 0.0,
4135                            "p10": 0.2,
4136                            "p50": 0.4
4137                        },
4138                    ],
4139                    "condition": {
4140                        "op":"eq",
4141                        "name": "event.contexts.browser.name",
4142                        "value": "Chrome"
4143                    }
4144                }
4145            ]
4146        }))
4147        .unwrap();
4148
4149        normalize_performance_score(&mut event, Some(&performance_score));
4150
4151        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4152        {
4153          "type": "transaction",
4154          "timestamp": 1619420405.0,
4155          "start_timestamp": 1619420400.0,
4156          "contexts": {
4157            "browser": {
4158              "name": "Chrome",
4159              "version": "120.1.1",
4160              "type": "browser",
4161            },
4162          },
4163          "measurements": {
4164            "cls": {
4165              "value": 0.11,
4166            },
4167            "fcp": {
4168              "value": 1237.0,
4169              "unit": "millisecond",
4170            },
4171            "fid": {
4172              "value": 213.0,
4173              "unit": "millisecond",
4174            },
4175            "lcp": {
4176              "value": 6596.0,
4177              "unit": "millisecond",
4178            },
4179            "score.cls": {
4180              "value": 0.21864170607444863,
4181              "unit": "ratio",
4182            },
4183            "score.fcp": {
4184              "value": 0.10750855443790831,
4185              "unit": "ratio",
4186            },
4187            "score.fid": {
4188              "value": 0.19657361348282545,
4189              "unit": "ratio",
4190            },
4191            "score.lcp": {
4192              "value": 0.009238896571386584,
4193              "unit": "ratio",
4194            },
4195            "score.ratio.cls": {
4196              "value": 0.8745668242977945,
4197              "unit": "ratio",
4198            },
4199            "score.ratio.fcp": {
4200              "value": 0.7167236962527221,
4201              "unit": "ratio",
4202            },
4203            "score.ratio.fid": {
4204              "value": 0.6552453782760849,
4205              "unit": "ratio",
4206            },
4207            "score.ratio.lcp": {
4208              "value": 0.03079632190462195,
4209              "unit": "ratio",
4210            },
4211            "score.total": {
4212              "value": 0.531962770566569,
4213              "unit": "ratio",
4214            },
4215            "score.weight.cls": {
4216              "value": 0.25,
4217              "unit": "ratio",
4218            },
4219            "score.weight.fcp": {
4220              "value": 0.15,
4221              "unit": "ratio",
4222            },
4223            "score.weight.fid": {
4224              "value": 0.3,
4225              "unit": "ratio",
4226            },
4227            "score.weight.lcp": {
4228              "value": 0.3,
4229              "unit": "ratio",
4230            },
4231            "score.weight.ttfb": {
4232              "value": 0.0,
4233              "unit": "ratio",
4234            },
4235          },
4236        }
4237        "###);
4238    }
4239
4240    #[test]
4241    fn test_computed_performance_score_missing_measurement() {
4242        let json = r#"
4243        {
4244            "type": "transaction",
4245            "timestamp": "2021-04-26T08:00:05+0100",
4246            "start_timestamp": "2021-04-26T08:00:00+0100",
4247            "measurements": {
4248                "a": {"value": 213, "unit": "millisecond"}
4249            },
4250            "contexts": {
4251                "browser": {
4252                    "name": "Chrome",
4253                    "version": "120.1.1",
4254                    "type": "browser"
4255                }
4256            }
4257        }
4258        "#;
4259
4260        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4261
4262        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4263            "profiles": [
4264                {
4265                    "name": "Desktop",
4266                    "scoreComponents": [
4267                        {
4268                            "measurement": "a",
4269                            "weight": 0.15,
4270                            "p10": 900,
4271                            "p50": 1600
4272                        },
4273                        {
4274                            "measurement": "b",
4275                            "weight": 0.30,
4276                            "p10": 1200,
4277                            "p50": 2400
4278                        },
4279                    ],
4280                    "condition": {
4281                        "op":"eq",
4282                        "name": "event.contexts.browser.name",
4283                        "value": "Chrome"
4284                    }
4285                }
4286            ]
4287        }))
4288        .unwrap();
4289
4290        normalize_performance_score(&mut event, Some(&performance_score));
4291
4292        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4293        {
4294          "type": "transaction",
4295          "timestamp": 1619420405.0,
4296          "start_timestamp": 1619420400.0,
4297          "contexts": {
4298            "browser": {
4299              "name": "Chrome",
4300              "version": "120.1.1",
4301              "type": "browser",
4302            },
4303          },
4304          "measurements": {
4305            "a": {
4306              "value": 213.0,
4307              "unit": "millisecond",
4308            },
4309          },
4310        }
4311        "###);
4312    }
4313
4314    #[test]
4315    fn test_computed_performance_score_optional_measurement() {
4316        let json = r#"
4317        {
4318            "type": "transaction",
4319            "timestamp": "2021-04-26T08:00:05+0100",
4320            "start_timestamp": "2021-04-26T08:00:00+0100",
4321            "measurements": {
4322                "a": {"value": 213, "unit": "millisecond"},
4323                "b": {"value": 213, "unit": "millisecond"}
4324            },
4325            "contexts": {
4326                "browser": {
4327                    "name": "Chrome",
4328                    "version": "120.1.1",
4329                    "type": "browser"
4330                }
4331            }
4332        }
4333        "#;
4334
4335        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4336
4337        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4338            "profiles": [
4339                {
4340                    "name": "Desktop",
4341                    "scoreComponents": [
4342                        {
4343                            "measurement": "a",
4344                            "weight": 0.15,
4345                            "p10": 900,
4346                            "p50": 1600,
4347                        },
4348                        {
4349                            "measurement": "b",
4350                            "weight": 0.30,
4351                            "p10": 1200,
4352                            "p50": 2400,
4353                            "optional": true
4354                        },
4355                        {
4356                            "measurement": "c",
4357                            "weight": 0.55,
4358                            "p10": 1200,
4359                            "p50": 2400,
4360                            "optional": true
4361                        },
4362                    ],
4363                    "condition": {
4364                        "op":"eq",
4365                        "name": "event.contexts.browser.name",
4366                        "value": "Chrome"
4367                    }
4368                }
4369            ]
4370        }))
4371        .unwrap();
4372
4373        normalize_performance_score(&mut event, Some(&performance_score));
4374
4375        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4376        {
4377          "type": "transaction",
4378          "timestamp": 1619420405.0,
4379          "start_timestamp": 1619420400.0,
4380          "contexts": {
4381            "browser": {
4382              "name": "Chrome",
4383              "version": "120.1.1",
4384              "type": "browser",
4385            },
4386          },
4387          "measurements": {
4388            "a": {
4389              "value": 213.0,
4390              "unit": "millisecond",
4391            },
4392            "b": {
4393              "value": 213.0,
4394              "unit": "millisecond",
4395            },
4396            "score.a": {
4397              "value": 0.33333215313291975,
4398              "unit": "ratio",
4399            },
4400            "score.b": {
4401              "value": 0.66666415149198,
4402              "unit": "ratio",
4403            },
4404            "score.ratio.a": {
4405              "value": 0.9999964593987591,
4406              "unit": "ratio",
4407            },
4408            "score.ratio.b": {
4409              "value": 0.9999962272379699,
4410              "unit": "ratio",
4411            },
4412            "score.total": {
4413              "value": 0.9999963046248997,
4414              "unit": "ratio",
4415            },
4416            "score.weight.a": {
4417              "value": 0.33333333333333337,
4418              "unit": "ratio",
4419            },
4420            "score.weight.b": {
4421              "value": 0.6666666666666667,
4422              "unit": "ratio",
4423            },
4424            "score.weight.c": {
4425              "value": 0.0,
4426              "unit": "ratio",
4427            },
4428          },
4429        }
4430        "###);
4431    }
4432
4433    #[test]
4434    fn test_computed_performance_score_weight_0() {
4435        let json = r#"
4436        {
4437            "type": "transaction",
4438            "timestamp": "2021-04-26T08:00:05+0100",
4439            "start_timestamp": "2021-04-26T08:00:00+0100",
4440            "measurements": {
4441                "cls": {"value": 0.11}
4442            }
4443        }
4444        "#;
4445
4446        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4447
4448        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4449            "profiles": [
4450                {
4451                    "name": "Desktop",
4452                    "scoreComponents": [
4453                        {
4454                            "measurement": "cls",
4455                            "weight": 0,
4456                            "p10": 0.1,
4457                            "p50": 0.25
4458                        },
4459                    ],
4460                    "condition": {
4461                        "op":"and",
4462                        "inner": []
4463                    }
4464                }
4465            ]
4466        }))
4467        .unwrap();
4468
4469        normalize_performance_score(&mut event, Some(&performance_score));
4470
4471        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4472        {
4473          "type": "transaction",
4474          "timestamp": 1619420405.0,
4475          "start_timestamp": 1619420400.0,
4476          "measurements": {
4477            "cls": {
4478              "value": 0.11,
4479            },
4480          },
4481        }
4482        "###);
4483    }
4484
4485    #[test]
4486    fn test_computed_performance_score_negative_value() {
4487        let json = r#"
4488        {
4489            "type": "transaction",
4490            "timestamp": "2021-04-26T08:00:05+0100",
4491            "start_timestamp": "2021-04-26T08:00:00+0100",
4492            "measurements": {
4493                "ttfb": {"value": -100, "unit": "millisecond"}
4494            }
4495        }
4496        "#;
4497
4498        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4499
4500        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4501            "profiles": [
4502                {
4503                    "name": "Desktop",
4504                    "scoreComponents": [
4505                        {
4506                            "measurement": "ttfb",
4507                            "weight": 1.0,
4508                            "p10": 100.0,
4509                            "p50": 250.0
4510                        },
4511                    ],
4512                    "condition": {
4513                        "op":"and",
4514                        "inner": []
4515                    }
4516                }
4517            ]
4518        }))
4519        .unwrap();
4520
4521        normalize_performance_score(&mut event, Some(&performance_score));
4522
4523        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4524        {
4525          "type": "transaction",
4526          "timestamp": 1619420405.0,
4527          "start_timestamp": 1619420400.0,
4528          "measurements": {
4529            "score.ratio.ttfb": {
4530              "value": 1.0,
4531              "unit": "ratio",
4532            },
4533            "score.total": {
4534              "value": 1.0,
4535              "unit": "ratio",
4536            },
4537            "score.ttfb": {
4538              "value": 1.0,
4539              "unit": "ratio",
4540            },
4541            "score.weight.ttfb": {
4542              "value": 1.0,
4543              "unit": "ratio",
4544            },
4545            "ttfb": {
4546              "value": -100.0,
4547              "unit": "millisecond",
4548            },
4549          },
4550        }
4551        "###);
4552    }
4553
4554    #[test]
4555    fn test_filter_negative_web_vital_measurements() {
4556        let json = r#"
4557        {
4558            "type": "transaction",
4559            "timestamp": "2021-04-26T08:00:05+0100",
4560            "start_timestamp": "2021-04-26T08:00:00+0100",
4561            "measurements": {
4562                "ttfb": {"value": -100, "unit": "millisecond"}
4563            }
4564        }
4565        "#;
4566        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4567
4568        // Allow ttfb as a builtinMeasurement with allow_negative defaulted to false.
4569        let project_measurement_config: MeasurementsConfig = serde_json::from_value(json!({
4570            "builtinMeasurements": [
4571                {"name": "ttfb", "unit": "millisecond"},
4572            ],
4573        }))
4574        .unwrap();
4575
4576        let dynamic_measurement_config =
4577            CombinedMeasurementsConfig::new(Some(&project_measurement_config), None);
4578
4579        normalize_event_measurements(&mut event, Some(dynamic_measurement_config), None);
4580
4581        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4582        {
4583          "type": "transaction",
4584          "timestamp": 1619420405.0,
4585          "start_timestamp": 1619420400.0,
4586          "measurements": {},
4587          "_meta": {
4588            "measurements": {
4589              "": Meta(Some(MetaInner(
4590                err: [
4591                  [
4592                    "invalid_data",
4593                    {
4594                      "reason": "Negative value for measurement ttfb not allowed: -100",
4595                    },
4596                  ],
4597                ],
4598                val: Some({
4599                  "ttfb": {
4600                    "unit": "millisecond",
4601                    "value": -100.0,
4602                  },
4603                }),
4604              ))),
4605            },
4606          },
4607        }
4608        "###);
4609    }
4610
4611    #[test]
4612    fn test_computed_performance_score_multiple_profiles() {
4613        let json = r#"
4614        {
4615            "type": "transaction",
4616            "timestamp": "2021-04-26T08:00:05+0100",
4617            "start_timestamp": "2021-04-26T08:00:00+0100",
4618            "measurements": {
4619                "cls": {"value": 0.11},
4620                "inp": {"value": 120.0}
4621            }
4622        }
4623        "#;
4624
4625        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4626
4627        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4628            "profiles": [
4629                {
4630                    "name": "Desktop",
4631                    "scoreComponents": [
4632                        {
4633                            "measurement": "cls",
4634                            "weight": 0,
4635                            "p10": 0.1,
4636                            "p50": 0.25
4637                        },
4638                    ],
4639                    "condition": {
4640                        "op":"and",
4641                        "inner": []
4642                    }
4643                },
4644                {
4645                    "name": "Desktop",
4646                    "scoreComponents": [
4647                        {
4648                            "measurement": "inp",
4649                            "weight": 1.0,
4650                            "p10": 0.1,
4651                            "p50": 0.25
4652                        },
4653                    ],
4654                    "condition": {
4655                        "op":"and",
4656                        "inner": []
4657                    }
4658                }
4659            ]
4660        }))
4661        .unwrap();
4662
4663        normalize_performance_score(&mut event, Some(&performance_score));
4664
4665        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4666        {
4667          "type": "transaction",
4668          "timestamp": 1619420405.0,
4669          "start_timestamp": 1619420400.0,
4670          "measurements": {
4671            "cls": {
4672              "value": 0.11,
4673            },
4674            "inp": {
4675              "value": 120.0,
4676            },
4677            "score.inp": {
4678              "value": 0.0,
4679              "unit": "ratio",
4680            },
4681            "score.ratio.inp": {
4682              "value": 0.0,
4683              "unit": "ratio",
4684            },
4685            "score.total": {
4686              "value": 0.0,
4687              "unit": "ratio",
4688            },
4689            "score.weight.inp": {
4690              "value": 1.0,
4691              "unit": "ratio",
4692            },
4693          },
4694        }
4695        "###);
4696    }
4697
4698    #[test]
4699    fn test_compute_performance_score_for_mobile_ios_profile() {
4700        let mut event = Annotated::<Event>::from_json(IOS_MOBILE_EVENT)
4701            .unwrap()
4702            .0
4703            .unwrap();
4704
4705        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4706            "profiles": [
4707                {
4708                    "name": "Mobile",
4709                    "scoreComponents": [
4710                        {
4711                            "measurement": "time_to_initial_display",
4712                            "weight": 0.25,
4713                            "p10": 1800.0,
4714                            "p50": 3000.0,
4715                            "optional": true
4716                        },
4717                        {
4718                            "measurement": "time_to_full_display",
4719                            "weight": 0.25,
4720                            "p10": 2500.0,
4721                            "p50": 4000.0,
4722                            "optional": true
4723                        },
4724                        {
4725                            "measurement": "app_start_warm",
4726                            "weight": 0.25,
4727                            "p10": 200.0,
4728                            "p50": 500.0,
4729                            "optional": true
4730                        },
4731                        {
4732                            "measurement": "app_start_cold",
4733                            "weight": 0.25,
4734                            "p10": 200.0,
4735                            "p50": 500.0,
4736                            "optional": true
4737                        }
4738                    ],
4739                    "condition": {
4740                        "op": "and",
4741                        "inner": [
4742                            {
4743                                "op": "or",
4744                                "inner": [
4745                                    {
4746                                        "op": "eq",
4747                                        "name": "event.sdk.name",
4748                                        "value": "sentry.cocoa"
4749                                    },
4750                                    {
4751                                        "op": "eq",
4752                                        "name": "event.sdk.name",
4753                                        "value": "sentry.java.android"
4754                                    }
4755                                ]
4756                            },
4757                            {
4758                                "op": "eq",
4759                                "name": "event.contexts.trace.op",
4760                                "value": "ui.load"
4761                            }
4762                        ]
4763                    }
4764                }
4765            ]
4766        }))
4767        .unwrap();
4768
4769        normalize_performance_score(&mut event, Some(&performance_score));
4770
4771        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {});
4772    }
4773
4774    #[test]
4775    fn test_compute_performance_score_for_mobile_android_profile() {
4776        let mut event = Annotated::<Event>::from_json(ANDROID_MOBILE_EVENT)
4777            .unwrap()
4778            .0
4779            .unwrap();
4780
4781        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4782            "profiles": [
4783                {
4784                    "name": "Mobile",
4785                    "scoreComponents": [
4786                        {
4787                            "measurement": "time_to_initial_display",
4788                            "weight": 0.25,
4789                            "p10": 1800.0,
4790                            "p50": 3000.0,
4791                            "optional": true
4792                        },
4793                        {
4794                            "measurement": "time_to_full_display",
4795                            "weight": 0.25,
4796                            "p10": 2500.0,
4797                            "p50": 4000.0,
4798                            "optional": true
4799                        },
4800                        {
4801                            "measurement": "app_start_warm",
4802                            "weight": 0.25,
4803                            "p10": 200.0,
4804                            "p50": 500.0,
4805                            "optional": true
4806                        },
4807                        {
4808                            "measurement": "app_start_cold",
4809                            "weight": 0.25,
4810                            "p10": 200.0,
4811                            "p50": 500.0,
4812                            "optional": true
4813                        }
4814                    ],
4815                    "condition": {
4816                        "op": "and",
4817                        "inner": [
4818                            {
4819                                "op": "or",
4820                                "inner": [
4821                                    {
4822                                        "op": "eq",
4823                                        "name": "event.sdk.name",
4824                                        "value": "sentry.cocoa"
4825                                    },
4826                                    {
4827                                        "op": "eq",
4828                                        "name": "event.sdk.name",
4829                                        "value": "sentry.java.android"
4830                                    }
4831                                ]
4832                            },
4833                            {
4834                                "op": "eq",
4835                                "name": "event.contexts.trace.op",
4836                                "value": "ui.load"
4837                            }
4838                        ]
4839                    }
4840                }
4841            ]
4842        }))
4843        .unwrap();
4844
4845        normalize_performance_score(&mut event, Some(&performance_score));
4846
4847        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {});
4848    }
4849
4850    #[test]
4851    fn test_computes_performance_score_and_tags_with_profile_version() {
4852        let json = r#"
4853        {
4854            "type": "transaction",
4855            "timestamp": "2021-04-26T08:00:05+0100",
4856            "start_timestamp": "2021-04-26T08:00:00+0100",
4857            "measurements": {
4858                "inp": {"value": 120.0}
4859            }
4860        }
4861        "#;
4862
4863        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4864
4865        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4866            "profiles": [
4867                {
4868                    "name": "Desktop",
4869                    "scoreComponents": [
4870                        {
4871                            "measurement": "inp",
4872                            "weight": 1.0,
4873                            "p10": 0.1,
4874                            "p50": 0.25
4875                        },
4876                    ],
4877                    "condition": {
4878                        "op":"and",
4879                        "inner": []
4880                    },
4881                    "version": "beta"
4882                }
4883            ]
4884        }))
4885        .unwrap();
4886
4887        normalize(
4888            &mut event,
4889            &mut Meta::default(),
4890            &NormalizationConfig {
4891                performance_score: Some(&performance_score),
4892                ..Default::default()
4893            },
4894        );
4895
4896        insta::assert_ron_snapshot!(SerializableAnnotated(&event.contexts), {}, @r###"
4897        {
4898          "performance_score": {
4899            "score_profile_version": "beta",
4900            "type": "performancescore",
4901          },
4902        }
4903        "###);
4904        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
4905        {
4906          "inp": {
4907            "value": 120.0,
4908            "unit": "millisecond",
4909          },
4910          "score.inp": {
4911            "value": 0.0,
4912            "unit": "ratio",
4913          },
4914          "score.ratio.inp": {
4915            "value": 0.0,
4916            "unit": "ratio",
4917          },
4918          "score.total": {
4919            "value": 0.0,
4920            "unit": "ratio",
4921          },
4922          "score.weight.inp": {
4923            "value": 1.0,
4924            "unit": "ratio",
4925          },
4926        }
4927        "###);
4928    }
4929
4930    #[test]
4931    fn test_normalize_adds_trace_context() {
4932        let json = r#"
4933        {
4934            "type": "error",
4935            "exception": {
4936                "values": [{"type": "ValueError", "value": "Should not happen"}]
4937            }
4938        }
4939        "#;
4940
4941        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4942
4943        normalize(
4944            &mut event,
4945            &mut Meta::default(),
4946            &NormalizationConfig {
4947                force_trace_context: true,
4948                ..Default::default()
4949            },
4950        );
4951
4952        insta::assert_ron_snapshot!(SerializableAnnotated(&event.contexts), {
4953            ".event_id" => "[event-id]",
4954            ".trace.trace_id" => "[trace-id]",
4955            ".trace.span_id" => "[span-id]"
4956        }, @r#"
4957        {
4958          "trace": {
4959            "trace_id": "[trace-id]",
4960            "span_id": "[span-id]",
4961            "status": "unknown",
4962            "type": "trace",
4963          },
4964        }
4965        "#);
4966    }
4967
4968    #[test]
4969    fn test_computes_standalone_cls_performance_score() {
4970        let json = r#"
4971        {
4972            "type": "transaction",
4973            "timestamp": "2021-04-26T08:00:05+0100",
4974            "start_timestamp": "2021-04-26T08:00:00+0100",
4975            "measurements": {
4976                "cls": {"value": 0.5}
4977            }
4978        }
4979        "#;
4980
4981        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4982
4983        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4984            "profiles": [
4985            {
4986                "name": "Default",
4987                "scoreComponents": [
4988                    {
4989                        "measurement": "fcp",
4990                        "weight": 0.15,
4991                        "p10": 900.0,
4992                        "p50": 1600.0,
4993                        "optional": true,
4994                    },
4995                    {
4996                        "measurement": "lcp",
4997                        "weight": 0.30,
4998                        "p10": 1200.0,
4999                        "p50": 2400.0,
5000                        "optional": true,
5001                    },
5002                    {
5003                        "measurement": "cls",
5004                        "weight": 0.15,
5005                        "p10": 0.1,
5006                        "p50": 0.25,
5007                        "optional": true,
5008                    },
5009                    {
5010                        "measurement": "ttfb",
5011                        "weight": 0.10,
5012                        "p10": 200.0,
5013                        "p50": 400.0,
5014                        "optional": true,
5015                    },
5016                ],
5017                "condition": {
5018                    "op": "and",
5019                    "inner": [],
5020                },
5021            }
5022            ]
5023        }))
5024        .unwrap();
5025
5026        normalize(
5027            &mut event,
5028            &mut Meta::default(),
5029            &NormalizationConfig {
5030                performance_score: Some(&performance_score),
5031                ..Default::default()
5032            },
5033        );
5034
5035        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
5036        {
5037          "cls": {
5038            "value": 0.5,
5039            "unit": "none",
5040          },
5041          "score.cls": {
5042            "value": 0.16615877613713903,
5043            "unit": "ratio",
5044          },
5045          "score.ratio.cls": {
5046            "value": 0.16615877613713903,
5047            "unit": "ratio",
5048          },
5049          "score.total": {
5050            "value": 0.16615877613713903,
5051            "unit": "ratio",
5052          },
5053          "score.weight.cls": {
5054            "value": 1.0,
5055            "unit": "ratio",
5056          },
5057          "score.weight.fcp": {
5058            "value": 0.0,
5059            "unit": "ratio",
5060          },
5061          "score.weight.lcp": {
5062            "value": 0.0,
5063            "unit": "ratio",
5064          },
5065          "score.weight.ttfb": {
5066            "value": 0.0,
5067            "unit": "ratio",
5068          },
5069        }
5070        "###);
5071    }
5072
5073    #[test]
5074    fn test_computes_standalone_lcp_performance_score() {
5075        let json = r#"
5076        {
5077            "type": "transaction",
5078            "timestamp": "2021-04-26T08:00:05+0100",
5079            "start_timestamp": "2021-04-26T08:00:00+0100",
5080            "measurements": {
5081                "lcp": {"value": 1200.0}
5082            }
5083        }
5084        "#;
5085
5086        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5087
5088        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5089            "profiles": [
5090            {
5091                "name": "Default",
5092                "scoreComponents": [
5093                    {
5094                        "measurement": "fcp",
5095                        "weight": 0.15,
5096                        "p10": 900.0,
5097                        "p50": 1600.0,
5098                        "optional": true,
5099                    },
5100                    {
5101                        "measurement": "lcp",
5102                        "weight": 0.30,
5103                        "p10": 1200.0,
5104                        "p50": 2400.0,
5105                        "optional": true,
5106                    },
5107                    {
5108                        "measurement": "cls",
5109                        "weight": 0.15,
5110                        "p10": 0.1,
5111                        "p50": 0.25,
5112                        "optional": true,
5113                    },
5114                    {
5115                        "measurement": "ttfb",
5116                        "weight": 0.10,
5117                        "p10": 200.0,
5118                        "p50": 400.0,
5119                        "optional": true,
5120                    },
5121                ],
5122                "condition": {
5123                    "op": "and",
5124                    "inner": [],
5125                },
5126            }
5127            ]
5128        }))
5129        .unwrap();
5130
5131        normalize(
5132            &mut event,
5133            &mut Meta::default(),
5134            &NormalizationConfig {
5135                performance_score: Some(&performance_score),
5136                ..Default::default()
5137            },
5138        );
5139
5140        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
5141        {
5142          "lcp": {
5143            "value": 1200.0,
5144            "unit": "millisecond",
5145          },
5146          "score.lcp": {
5147            "value": 0.8999999314038525,
5148            "unit": "ratio",
5149          },
5150          "score.ratio.lcp": {
5151            "value": 0.8999999314038525,
5152            "unit": "ratio",
5153          },
5154          "score.total": {
5155            "value": 0.8999999314038525,
5156            "unit": "ratio",
5157          },
5158          "score.weight.cls": {
5159            "value": 0.0,
5160            "unit": "ratio",
5161          },
5162          "score.weight.fcp": {
5163            "value": 0.0,
5164            "unit": "ratio",
5165          },
5166          "score.weight.lcp": {
5167            "value": 1.0,
5168            "unit": "ratio",
5169          },
5170          "score.weight.ttfb": {
5171            "value": 0.0,
5172            "unit": "ratio",
5173          },
5174        }
5175        "###);
5176    }
5177
5178    #[test]
5179    fn test_computed_performance_score_uses_first_matching_profile() {
5180        let json = r#"
5181        {
5182            "type": "transaction",
5183            "timestamp": "2021-04-26T08:00:05+0100",
5184            "start_timestamp": "2021-04-26T08:00:00+0100",
5185            "measurements": {
5186                "a": {"value": 213, "unit": "millisecond"},
5187                "b": {"value": 213, "unit": "millisecond"}
5188            },
5189            "contexts": {
5190                "browser": {
5191                    "name": "Chrome",
5192                    "version": "120.1.1",
5193                    "type": "browser"
5194                }
5195            }
5196        }
5197        "#;
5198
5199        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5200
5201        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5202            "profiles": [
5203                {
5204                    "name": "Mobile",
5205                    "scoreComponents": [
5206                        {
5207                            "measurement": "a",
5208                            "weight": 0.15,
5209                            "p10": 100,
5210                            "p50": 200,
5211                        },
5212                        {
5213                            "measurement": "b",
5214                            "weight": 0.30,
5215                            "p10": 100,
5216                            "p50": 200,
5217                            "optional": true
5218                        },
5219                        {
5220                            "measurement": "c",
5221                            "weight": 0.55,
5222                            "p10": 100,
5223                            "p50": 200,
5224                            "optional": true
5225                        },
5226                    ],
5227                    "condition": {
5228                        "op":"eq",
5229                        "name": "event.contexts.browser.name",
5230                        "value": "Chrome Mobile"
5231                    }
5232                },
5233                {
5234                    "name": "Desktop",
5235                    "scoreComponents": [
5236                        {
5237                            "measurement": "a",
5238                            "weight": 0.15,
5239                            "p10": 900,
5240                            "p50": 1600,
5241                        },
5242                        {
5243                            "measurement": "b",
5244                            "weight": 0.30,
5245                            "p10": 1200,
5246                            "p50": 2400,
5247                            "optional": true
5248                        },
5249                        {
5250                            "measurement": "c",
5251                            "weight": 0.55,
5252                            "p10": 1200,
5253                            "p50": 2400,
5254                            "optional": true
5255                        },
5256                    ],
5257                    "condition": {
5258                        "op":"eq",
5259                        "name": "event.contexts.browser.name",
5260                        "value": "Chrome"
5261                    }
5262                },
5263                {
5264                    "name": "Default",
5265                    "scoreComponents": [
5266                        {
5267                            "measurement": "a",
5268                            "weight": 0.15,
5269                            "p10": 100,
5270                            "p50": 200,
5271                        },
5272                        {
5273                            "measurement": "b",
5274                            "weight": 0.30,
5275                            "p10": 100,
5276                            "p50": 200,
5277                            "optional": true
5278                        },
5279                        {
5280                            "measurement": "c",
5281                            "weight": 0.55,
5282                            "p10": 100,
5283                            "p50": 200,
5284                            "optional": true
5285                        },
5286                    ],
5287                    "condition": {
5288                        "op": "and",
5289                        "inner": [],
5290                    }
5291                }
5292            ]
5293        }))
5294        .unwrap();
5295
5296        normalize_performance_score(&mut event, Some(&performance_score));
5297
5298        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5299        {
5300          "type": "transaction",
5301          "timestamp": 1619420405.0,
5302          "start_timestamp": 1619420400.0,
5303          "contexts": {
5304            "browser": {
5305              "name": "Chrome",
5306              "version": "120.1.1",
5307              "type": "browser",
5308            },
5309          },
5310          "measurements": {
5311            "a": {
5312              "value": 213.0,
5313              "unit": "millisecond",
5314            },
5315            "b": {
5316              "value": 213.0,
5317              "unit": "millisecond",
5318            },
5319            "score.a": {
5320              "value": 0.33333215313291975,
5321              "unit": "ratio",
5322            },
5323            "score.b": {
5324              "value": 0.66666415149198,
5325              "unit": "ratio",
5326            },
5327            "score.ratio.a": {
5328              "value": 0.9999964593987591,
5329              "unit": "ratio",
5330            },
5331            "score.ratio.b": {
5332              "value": 0.9999962272379699,
5333              "unit": "ratio",
5334            },
5335            "score.total": {
5336              "value": 0.9999963046248997,
5337              "unit": "ratio",
5338            },
5339            "score.weight.a": {
5340              "value": 0.33333333333333337,
5341              "unit": "ratio",
5342            },
5343            "score.weight.b": {
5344              "value": 0.6666666666666667,
5345              "unit": "ratio",
5346            },
5347            "score.weight.c": {
5348              "value": 0.0,
5349              "unit": "ratio",
5350            },
5351          },
5352        }
5353        "###);
5354    }
5355
5356    #[test]
5357    fn test_computed_performance_score_falls_back_to_default_profile() {
5358        let json = r#"
5359        {
5360            "type": "transaction",
5361            "timestamp": "2021-04-26T08:00:05+0100",
5362            "start_timestamp": "2021-04-26T08:00:00+0100",
5363            "measurements": {
5364                "a": {"value": 213, "unit": "millisecond"},
5365                "b": {"value": 213, "unit": "millisecond"}
5366            },
5367            "contexts": {}
5368        }
5369        "#;
5370
5371        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5372
5373        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5374            "profiles": [
5375                {
5376                    "name": "Mobile",
5377                    "scoreComponents": [
5378                        {
5379                            "measurement": "a",
5380                            "weight": 0.15,
5381                            "p10": 900,
5382                            "p50": 1600,
5383                            "optional": true
5384                        },
5385                        {
5386                            "measurement": "b",
5387                            "weight": 0.30,
5388                            "p10": 1200,
5389                            "p50": 2400,
5390                            "optional": true
5391                        },
5392                        {
5393                            "measurement": "c",
5394                            "weight": 0.55,
5395                            "p10": 1200,
5396                            "p50": 2400,
5397                            "optional": true
5398                        },
5399                    ],
5400                    "condition": {
5401                        "op":"eq",
5402                        "name": "event.contexts.browser.name",
5403                        "value": "Chrome Mobile"
5404                    }
5405                },
5406                {
5407                    "name": "Desktop",
5408                    "scoreComponents": [
5409                        {
5410                            "measurement": "a",
5411                            "weight": 0.15,
5412                            "p10": 900,
5413                            "p50": 1600,
5414                            "optional": true
5415                        },
5416                        {
5417                            "measurement": "b",
5418                            "weight": 0.30,
5419                            "p10": 1200,
5420                            "p50": 2400,
5421                            "optional": true
5422                        },
5423                        {
5424                            "measurement": "c",
5425                            "weight": 0.55,
5426                            "p10": 1200,
5427                            "p50": 2400,
5428                            "optional": true
5429                        },
5430                    ],
5431                    "condition": {
5432                        "op":"eq",
5433                        "name": "event.contexts.browser.name",
5434                        "value": "Chrome"
5435                    }
5436                },
5437                {
5438                    "name": "Default",
5439                    "scoreComponents": [
5440                        {
5441                            "measurement": "a",
5442                            "weight": 0.15,
5443                            "p10": 100,
5444                            "p50": 200,
5445                            "optional": true
5446                        },
5447                        {
5448                            "measurement": "b",
5449                            "weight": 0.30,
5450                            "p10": 100,
5451                            "p50": 200,
5452                            "optional": true
5453                        },
5454                        {
5455                            "measurement": "c",
5456                            "weight": 0.55,
5457                            "p10": 100,
5458                            "p50": 200,
5459                            "optional": true
5460                        },
5461                    ],
5462                    "condition": {
5463                        "op": "and",
5464                        "inner": [],
5465                    }
5466                }
5467            ]
5468        }))
5469        .unwrap();
5470
5471        normalize_performance_score(&mut event, Some(&performance_score));
5472
5473        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5474        {
5475          "type": "transaction",
5476          "timestamp": 1619420405.0,
5477          "start_timestamp": 1619420400.0,
5478          "contexts": {},
5479          "measurements": {
5480            "a": {
5481              "value": 213.0,
5482              "unit": "millisecond",
5483            },
5484            "b": {
5485              "value": 213.0,
5486              "unit": "millisecond",
5487            },
5488            "score.a": {
5489              "value": 0.15121816827413334,
5490              "unit": "ratio",
5491            },
5492            "score.b": {
5493              "value": 0.3024363365482667,
5494              "unit": "ratio",
5495            },
5496            "score.ratio.a": {
5497              "value": 0.45365450482239994,
5498              "unit": "ratio",
5499            },
5500            "score.ratio.b": {
5501              "value": 0.45365450482239994,
5502              "unit": "ratio",
5503            },
5504            "score.total": {
5505              "value": 0.4536545048224,
5506              "unit": "ratio",
5507            },
5508            "score.weight.a": {
5509              "value": 0.33333333333333337,
5510              "unit": "ratio",
5511            },
5512            "score.weight.b": {
5513              "value": 0.6666666666666667,
5514              "unit": "ratio",
5515            },
5516            "score.weight.c": {
5517              "value": 0.0,
5518              "unit": "ratio",
5519            },
5520          },
5521        }
5522        "###);
5523    }
5524
5525    #[test]
5526    fn test_normalization_removes_reprocessing_context() {
5527        let json = r#"{
5528            "contexts": {
5529                "reprocessing": {}
5530            }
5531        }"#;
5532        let mut event = Annotated::<Event>::from_json(json).unwrap();
5533        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
5534        normalize_event(&mut event, &NormalizationConfig::default());
5535        assert!(!get_value!(event.contexts!).contains_key("reprocessing"));
5536    }
5537
5538    #[test]
5539    fn test_renormalization_does_not_remove_reprocessing_context() {
5540        let json = r#"{
5541            "contexts": {
5542                "reprocessing": {}
5543            }
5544        }"#;
5545        let mut event = Annotated::<Event>::from_json(json).unwrap();
5546        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
5547        normalize_event(
5548            &mut event,
5549            &NormalizationConfig {
5550                is_renormalize: true,
5551                ..Default::default()
5552            },
5553        );
5554        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
5555    }
5556
5557    #[test]
5558    fn test_normalize_user() {
5559        let json = r#"{
5560            "user": {
5561                "id": "123456",
5562                "username": "john",
5563                "other": "value"
5564            }
5565        }"#;
5566        let mut event = Annotated::<Event>::from_json(json).unwrap();
5567        normalize_user(event.value_mut().as_mut().unwrap());
5568
5569        let user = event.value().unwrap().user.value().unwrap();
5570        assert_eq!(user.data, {
5571            let mut map = Object::new();
5572            map.insert(
5573                "other".to_owned(),
5574                Annotated::new(Value::String("value".to_owned())),
5575            );
5576            Annotated::new(map)
5577        });
5578        assert_eq!(user.other, Object::new());
5579        assert_eq!(user.username, Annotated::new("john".to_owned().into()));
5580        assert_eq!(user.sentry_user, Annotated::new("id:123456".to_owned()));
5581    }
5582
5583    #[test]
5584    fn test_handle_types_in_spaced_exception_values() {
5585        let mut exception = Annotated::new(Exception {
5586            value: Annotated::new("ValueError: unauthorized".to_owned().into()),
5587            ..Exception::default()
5588        });
5589        normalize_exception(&mut exception);
5590
5591        let exception = exception.value().unwrap();
5592        assert_eq!(exception.value.as_str(), Some("unauthorized"));
5593        assert_eq!(exception.ty.as_str(), Some("ValueError"));
5594    }
5595
5596    #[test]
5597    fn test_handle_types_in_non_spaced_excepton_values() {
5598        let mut exception = Annotated::new(Exception {
5599            value: Annotated::new("ValueError:unauthorized".to_owned().into()),
5600            ..Exception::default()
5601        });
5602        normalize_exception(&mut exception);
5603
5604        let exception = exception.value().unwrap();
5605        assert_eq!(exception.value.as_str(), Some("unauthorized"));
5606        assert_eq!(exception.ty.as_str(), Some("ValueError"));
5607    }
5608
5609    #[test]
5610    fn test_rejects_empty_exception_fields() {
5611        let mut exception = Annotated::new(Exception {
5612            value: Annotated::new("".to_owned().into()),
5613            ty: Annotated::new("".to_owned()),
5614            ..Default::default()
5615        });
5616
5617        normalize_exception(&mut exception);
5618
5619        assert!(exception.value().is_none());
5620        assert!(exception.meta().has_errors());
5621    }
5622
5623    #[test]
5624    fn test_json_value() {
5625        let mut exception = Annotated::new(Exception {
5626            value: Annotated::new(r#"{"unauthorized":true}"#.to_owned().into()),
5627            ..Exception::default()
5628        });
5629
5630        normalize_exception(&mut exception);
5631
5632        let exception = exception.value().unwrap();
5633
5634        // Don't split a json-serialized value on the colon
5635        assert_eq!(exception.value.as_str(), Some(r#"{"unauthorized":true}"#));
5636        assert_eq!(exception.ty.value(), None);
5637    }
5638
5639    #[test]
5640    fn test_exception_invalid() {
5641        let mut exception = Annotated::new(Exception::default());
5642
5643        normalize_exception(&mut exception);
5644
5645        let expected = Error::with(ErrorKind::MissingAttribute, |error| {
5646            error.insert("attribute", "type or value");
5647        });
5648        assert_eq!(
5649            exception.meta().iter_errors().collect_tuple(),
5650            Some((&expected,))
5651        );
5652    }
5653
5654    #[test]
5655    fn test_normalize_exception() {
5656        let mut event = Annotated::new(Event {
5657            exceptions: Annotated::new(Values::new(vec![Annotated::new(Exception {
5658                // Exception with missing type and value
5659                ty: Annotated::empty(),
5660                value: Annotated::empty(),
5661                ..Default::default()
5662            })])),
5663            ..Default::default()
5664        });
5665
5666        normalize_event(&mut event, &NormalizationConfig::default());
5667
5668        let exception = event
5669            .value()
5670            .unwrap()
5671            .exceptions
5672            .value()
5673            .unwrap()
5674            .values
5675            .value()
5676            .unwrap()
5677            .first()
5678            .unwrap();
5679
5680        assert_debug_snapshot!(exception.meta(), @r###"
5681        Meta {
5682            remarks: [],
5683            errors: [
5684                Error {
5685                    kind: MissingAttribute,
5686                    data: {
5687                        "attribute": String(
5688                            "type or value",
5689                        ),
5690                    },
5691                },
5692            ],
5693            original_length: None,
5694            original_value: Some(
5695                Object(
5696                    {
5697                        "mechanism": ~,
5698                        "module": ~,
5699                        "raw_stacktrace": ~,
5700                        "stacktrace": ~,
5701                        "thread_id": ~,
5702                        "type": ~,
5703                        "value": ~,
5704                    },
5705                ),
5706            ),
5707        }
5708        "###);
5709    }
5710
5711    #[test]
5712    fn test_normalize_breadcrumbs() {
5713        let mut event = Event {
5714            breadcrumbs: Annotated::new(Values {
5715                values: Annotated::new(vec![Annotated::new(Breadcrumb::default())]),
5716                ..Default::default()
5717            }),
5718            ..Default::default()
5719        };
5720        normalize_breadcrumbs(&mut event);
5721
5722        let breadcrumb = event
5723            .breadcrumbs
5724            .value()
5725            .unwrap()
5726            .values
5727            .value()
5728            .unwrap()
5729            .first()
5730            .unwrap()
5731            .value()
5732            .unwrap();
5733        assert_eq!(breadcrumb.ty.value().unwrap(), "default");
5734        assert_eq!(&breadcrumb.level.value().unwrap().to_string(), "info");
5735    }
5736
5737    #[test]
5738    fn test_other_debug_images_have_meta_errors() {
5739        let mut event = Event {
5740            debug_meta: Annotated::new(DebugMeta {
5741                images: Annotated::new(vec![Annotated::new(
5742                    DebugImage::Other(BTreeMap::default()),
5743                )]),
5744                ..Default::default()
5745            }),
5746            ..Default::default()
5747        };
5748        normalize_debug_meta(&mut event);
5749
5750        let debug_image_meta = event
5751            .debug_meta
5752            .value()
5753            .unwrap()
5754            .images
5755            .value()
5756            .unwrap()
5757            .first()
5758            .unwrap()
5759            .meta();
5760        assert_debug_snapshot!(debug_image_meta, @r###"
5761        Meta {
5762            remarks: [],
5763            errors: [
5764                Error {
5765                    kind: InvalidData,
5766                    data: {
5767                        "reason": String(
5768                            "unsupported debug image type",
5769                        ),
5770                    },
5771                },
5772            ],
5773            original_length: None,
5774            original_value: Some(
5775                Object(
5776                    {},
5777                ),
5778            ),
5779        }
5780        "###);
5781    }
5782
5783    #[test]
5784    fn test_skip_span_normalization_when_configured() {
5785        let json = r#"{
5786            "type": "transaction",
5787            "start_timestamp": 1,
5788            "timestamp": 2,
5789            "contexts": {
5790                "trace": {
5791                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
5792                    "span_id": "aaaaaaaaaaaaaaaa"
5793                }
5794            },
5795            "spans": [
5796                {
5797                    "op": "db",
5798                    "description": "SELECT * FROM table;",
5799                    "start_timestamp": 1,
5800                    "timestamp": 2,
5801                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
5802                    "span_id": "bbbbbbbbbbbbbbbb",
5803                    "parent_span_id": "aaaaaaaaaaaaaaaa"
5804                }
5805            ]
5806        }"#;
5807
5808        let mut event = Annotated::<Event>::from_json(json).unwrap();
5809        assert!(get_value!(event.spans[0].exclusive_time).is_none());
5810        normalize_event(
5811            &mut event,
5812            &NormalizationConfig {
5813                is_renormalize: true,
5814                ..Default::default()
5815            },
5816        );
5817        assert!(get_value!(event.spans[0].exclusive_time).is_none());
5818        normalize_event(
5819            &mut event,
5820            &NormalizationConfig {
5821                is_renormalize: false,
5822                ..Default::default()
5823            },
5824        );
5825        assert!(get_value!(event.spans[0].exclusive_time).is_some());
5826    }
5827
5828    #[test]
5829    fn test_normalize_trace_context_tags_extracts_lcp_info() {
5830        let json = r#"{
5831            "type": "transaction",
5832            "start_timestamp": 1,
5833            "timestamp": 2,
5834            "contexts": {
5835                "trace": {
5836                    "data": {
5837                        "lcp.element": "body > div#app > div > h1#header",
5838                        "lcp.size": 24827,
5839                        "lcp.id": "header",
5840                        "lcp.url": "http://example.com/image.jpg"
5841                    }
5842                }
5843            },
5844            "measurements": {
5845                "lcp": { "value": 146.20000000298023, "unit": "millisecond" }
5846            }
5847        }"#;
5848        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5849        normalize_trace_context_tags(&mut event);
5850        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5851        {
5852          "type": "transaction",
5853          "timestamp": 2.0,
5854          "start_timestamp": 1.0,
5855          "contexts": {
5856            "trace": {
5857              "data": {
5858                "lcp.element": "body > div#app > div > h1#header",
5859                "lcp.size": 24827,
5860                "lcp.id": "header",
5861                "lcp.url": "http://example.com/image.jpg",
5862              },
5863              "type": "trace",
5864            },
5865          },
5866          "tags": [
5867            [
5868              "lcp.element",
5869              "body > div#app > div > h1#header",
5870            ],
5871            [
5872              "lcp.size",
5873              "24827",
5874            ],
5875            [
5876              "lcp.id",
5877              "header",
5878            ],
5879            [
5880              "lcp.url",
5881              "http://example.com/image.jpg",
5882            ],
5883          ],
5884          "measurements": {
5885            "lcp": {
5886              "value": 146.20000000298023,
5887              "unit": "millisecond",
5888            },
5889          },
5890        }
5891        "###);
5892    }
5893
5894    #[test]
5895    fn test_normalize_trace_context_tags_does_not_overwrite_lcp_tags() {
5896        let json = r#"{
5897          "type": "transaction",
5898          "start_timestamp": 1,
5899          "timestamp": 2,
5900          "contexts": {
5901              "trace": {
5902                  "data": {
5903                      "lcp.element": "body > div#app > div > h1#id",
5904                      "lcp.size": 33333,
5905                      "lcp.id": "id",
5906                      "lcp.url": "http://example.com/another-image.jpg"
5907                  }
5908              }
5909          },
5910          "tags": {
5911              "lcp.element": "body > div#app > div > h1#header",
5912              "lcp.size": 24827,
5913              "lcp.id": "header",
5914              "lcp.url": "http://example.com/image.jpg"
5915          },
5916          "measurements": {
5917              "lcp": { "value": 146.20000000298023, "unit": "millisecond" }
5918          }
5919        }"#;
5920        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5921        normalize_trace_context_tags(&mut event);
5922        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5923        {
5924          "type": "transaction",
5925          "timestamp": 2.0,
5926          "start_timestamp": 1.0,
5927          "contexts": {
5928            "trace": {
5929              "data": {
5930                "lcp.element": "body > div#app > div > h1#id",
5931                "lcp.size": 33333,
5932                "lcp.id": "id",
5933                "lcp.url": "http://example.com/another-image.jpg",
5934              },
5935              "type": "trace",
5936            },
5937          },
5938          "tags": [
5939            [
5940              "lcp.element",
5941              "body > div#app > div > h1#header",
5942            ],
5943            [
5944              "lcp.id",
5945              "header",
5946            ],
5947            [
5948              "lcp.size",
5949              "24827",
5950            ],
5951            [
5952              "lcp.url",
5953              "http://example.com/image.jpg",
5954            ],
5955          ],
5956          "measurements": {
5957            "lcp": {
5958              "value": 146.20000000298023,
5959              "unit": "millisecond",
5960            },
5961          },
5962        }
5963        "###);
5964    }
5965
5966    #[test]
5967    fn test_tags_are_trimmed() {
5968        let json = r#"
5969            {
5970                "tags": {
5971                    "key": "too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_",
5972                    "too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_": "value"
5973                }
5974            }
5975        "#;
5976
5977        let mut event = Annotated::<Event>::from_json(json).unwrap();
5978
5979        normalize_event(
5980            &mut event,
5981            &NormalizationConfig {
5982                enable_trimming: true,
5983                ..NormalizationConfig::default()
5984            },
5985        );
5986
5987        insta::assert_debug_snapshot!(get_value!(event.tags!), @r###"
5988        Tags(
5989            PairList(
5990                [
5991                    TagEntry(
5992                        "key",
5993                        Annotated(
5994                            "too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__lo...",
5995                            Meta {
5996                                remarks: [
5997                                    Remark {
5998                                        ty: Substituted,
5999                                        rule_id: "!limit",
6000                                        range: Some(
6001                                            (
6002                                                197,
6003                                                200,
6004                                            ),
6005                                        ),
6006                                    },
6007                                ],
6008                                errors: [],
6009                                original_length: Some(
6010                                    210,
6011                                ),
6012                                original_value: None,
6013                            },
6014                        ),
6015                    ),
6016                    TagEntry(
6017                        Annotated(
6018                            "too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__long_too__lo...",
6019                            Meta {
6020                                remarks: [
6021                                    Remark {
6022                                        ty: Substituted,
6023                                        rule_id: "!limit",
6024                                        range: Some(
6025                                            (
6026                                                197,
6027                                                200,
6028                                            ),
6029                                        ),
6030                                    },
6031                                ],
6032                                errors: [],
6033                                original_length: Some(
6034                                    210,
6035                                ),
6036                                original_value: None,
6037                            },
6038                        ),
6039                        "value",
6040                    ),
6041                ],
6042            ),
6043        )
6044        "###);
6045    }
6046}