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