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