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
1707    use relay_event_schema::protocol::SpanData;
1708    use relay_pattern::Pattern;
1709    use relay_protocol::assert_annotated_snapshot;
1710    use std::collections::BTreeMap;
1711    use std::collections::HashMap;
1712
1713    use insta::assert_debug_snapshot;
1714    use itertools::Itertools;
1715    use relay_event_schema::protocol::{Breadcrumb, Csp, DebugMeta, DeviceContext, Values};
1716    use relay_protocol::{SerializableAnnotated, get_value};
1717    use serde_json::json;
1718
1719    use super::*;
1720    use crate::eap;
1721    use crate::{ClientHints, MeasurementsConfig, ModelCostV2, ModelMetadataEntry};
1722
1723    const IOS_MOBILE_EVENT: &str = r#"
1724        {
1725            "sdk": {"name": "sentry.cocoa"},
1726            "contexts": {
1727                "trace": {
1728                    "op": "ui.load"
1729                }
1730            },
1731            "measurements": {
1732                "app_start_warm": {
1733                    "value": 8049.345970153808,
1734                    "unit": "millisecond"
1735                },
1736                "time_to_full_display": {
1737                    "value": 8240.571022033691,
1738                    "unit": "millisecond"
1739                },
1740                "time_to_initial_display": {
1741                    "value": 8049.345970153808,
1742                    "unit": "millisecond"
1743                }
1744            }
1745        }
1746        "#;
1747
1748    const ANDROID_MOBILE_EVENT: &str = r#"
1749        {
1750            "sdk": {"name": "sentry.java.android"},
1751            "contexts": {
1752                "trace": {
1753                    "op": "ui.load"
1754                }
1755            },
1756            "measurements": {
1757                "app_start_cold": {
1758                    "value": 22648,
1759                    "unit": "millisecond"
1760                },
1761                "time_to_full_display": {
1762                    "value": 22647,
1763                    "unit": "millisecond"
1764                },
1765                "time_to_initial_display": {
1766                    "value": 22647,
1767                    "unit": "millisecond"
1768                }
1769            }
1770        }
1771        "#;
1772
1773    fn collect_span_data<const N: usize>(event: Annotated<Event>) -> [Annotated<SpanData>; N] {
1774        get_value!(event.spans!)
1775            .iter()
1776            .map(|span| Annotated::new(get_value!(span.data!).clone()))
1777            .collect::<Vec<_>>()
1778            .try_into()
1779            .unwrap()
1780    }
1781
1782    fn trace_context_data(event: &Event) -> &Annotated<SpanData> {
1783        &event.context::<TraceContext>().unwrap().data
1784    }
1785
1786    fn app_vitals_start_screen_event(
1787        ty: &str,
1788        transaction: Option<&str>,
1789        trace_op: &str,
1790        measurement: Option<&str>,
1791        existing_screen: Option<&str>,
1792    ) -> Event {
1793        let mut payload = json!({
1794            "type": ty,
1795            "contexts": {"trace": {"op": trace_op}},
1796            "measurements": {},
1797        });
1798
1799        if let Some(transaction) = transaction {
1800            payload["transaction"] = json!(transaction);
1801        }
1802
1803        if let Some(measurement) = measurement {
1804            payload["measurements"] = json!({
1805                measurement: {"value": 1234.0, "unit": "millisecond"}
1806            });
1807        }
1808
1809        if let Some(screen) = existing_screen {
1810            payload["contexts"]["trace"]["data"] = json!({APP__VITALS__START__SCREEN: screen});
1811        }
1812
1813        Annotated::<Event>::from_json(&payload.to_string())
1814            .unwrap()
1815            .into_value()
1816            .unwrap()
1817    }
1818
1819    #[test]
1820    fn test_normalize_dist_none() {
1821        let mut dist = Annotated::default();
1822        normalize_dist(&mut dist);
1823        assert_eq!(dist.value(), None);
1824    }
1825
1826    #[test]
1827    fn test_normalize_dist_empty() {
1828        let mut dist = Annotated::new("".to_owned());
1829        normalize_dist(&mut dist);
1830        assert_eq!(dist.value(), None);
1831    }
1832
1833    #[test]
1834    fn test_normalize_dist_trim() {
1835        let mut dist = Annotated::new(" foo  ".to_owned());
1836        normalize_dist(&mut dist);
1837        assert_eq!(dist.value(), Some(&"foo".to_owned()));
1838    }
1839
1840    #[test]
1841    fn test_normalize_dist_whitespace() {
1842        let mut dist = Annotated::new(" ".to_owned());
1843        normalize_dist(&mut dist);
1844        assert_eq!(dist.value(), None);
1845    }
1846
1847    #[test]
1848    fn test_normalize_platform_and_level_with_transaction_event() {
1849        let json = r#"
1850        {
1851            "type": "transaction"
1852        }
1853        "#;
1854
1855        let Annotated(Some(mut event), mut meta) = Annotated::<Event>::from_json(json).unwrap()
1856        else {
1857            panic!("Invalid transaction json");
1858        };
1859
1860        normalize_default_attributes(&mut event, &mut meta, &NormalizationConfig::default());
1861
1862        assert_eq!(event.level.value().unwrap().to_string(), "info");
1863        assert_eq!(event.ty.value().unwrap().to_string(), "transaction");
1864        assert_eq!(event.platform.as_str().unwrap(), "other");
1865    }
1866
1867    #[test]
1868    fn test_normalize_platform_and_level_with_error_event() {
1869        let json = r#"
1870        {
1871            "type": "error",
1872            "exception": {
1873                "values": [{"type": "ValueError", "value": "Should not happen"}]
1874            }
1875        }
1876        "#;
1877
1878        let Annotated(Some(mut event), mut meta) = Annotated::<Event>::from_json(json).unwrap()
1879        else {
1880            panic!("Invalid error json");
1881        };
1882
1883        normalize_default_attributes(&mut event, &mut meta, &NormalizationConfig::default());
1884
1885        assert_eq!(event.level.value().unwrap().to_string(), "error");
1886        assert_eq!(event.ty.value().unwrap().to_string(), "error");
1887        assert_eq!(event.platform.value().unwrap().to_owned(), "other");
1888    }
1889
1890    #[test]
1891    fn test_computed_measurements() {
1892        let json = r#"
1893        {
1894            "type": "transaction",
1895            "timestamp": "2021-04-26T08:00:05+0100",
1896            "start_timestamp": "2021-04-26T08:00:00+0100",
1897            "measurements": {
1898                "frames_slow": {"value": 1},
1899                "frames_frozen": {"value": 2},
1900                "frames_total": {"value": 4},
1901                "stall_total_time": {"value": 4000, "unit": "millisecond"}
1902            }
1903        }
1904        "#;
1905
1906        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
1907
1908        normalize_event_measurements(&mut event, None, None);
1909
1910        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
1911        {
1912          "type": "transaction",
1913          "timestamp": 1619420405.0,
1914          "start_timestamp": 1619420400.0,
1915          "measurements": {
1916            "frames_frozen": {
1917              "value": 2.0,
1918              "unit": "none",
1919            },
1920            "frames_frozen_rate": {
1921              "value": 0.5,
1922              "unit": "ratio",
1923            },
1924            "frames_slow": {
1925              "value": 1.0,
1926              "unit": "none",
1927            },
1928            "frames_slow_rate": {
1929              "value": 0.25,
1930              "unit": "ratio",
1931            },
1932            "frames_total": {
1933              "value": 4.0,
1934              "unit": "none",
1935            },
1936            "stall_percentage": {
1937              "value": 0.8,
1938              "unit": "ratio",
1939            },
1940            "stall_total_time": {
1941              "value": 4000.0,
1942              "unit": "millisecond",
1943            },
1944          },
1945        }
1946        "###);
1947    }
1948
1949    #[test]
1950    fn test_filter_custom_measurements() {
1951        let json = r#"
1952        {
1953            "type": "transaction",
1954            "timestamp": "2021-04-26T08:00:05+0100",
1955            "start_timestamp": "2021-04-26T08:00:00+0100",
1956            "measurements": {
1957                "my_custom_measurement_1": {"value": 123},
1958                "frames_frozen": {"value": 666, "unit": "invalid_unit"},
1959                "frames_slow": {"value": 1},
1960                "my_custom_measurement_3": {"value": 456},
1961                "my_custom_measurement_2": {"value": 789}
1962            }
1963        }
1964        "#;
1965        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
1966
1967        let project_measurement_config: MeasurementsConfig = serde_json::from_value(json!({
1968            "builtinMeasurements": [
1969                {"name": "frames_frozen", "unit": "none"},
1970                {"name": "frames_slow", "unit": "none"}
1971            ],
1972            "maxCustomMeasurements": 2,
1973            "stray_key": "zzz"
1974        }))
1975        .unwrap();
1976
1977        let dynamic_measurement_config =
1978            CombinedMeasurementsConfig::new(Some(&project_measurement_config), None);
1979
1980        normalize_event_measurements(&mut event, Some(dynamic_measurement_config), None);
1981
1982        // Only two custom measurements are retained, in alphabetic order (1 and 2)
1983        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
1984        {
1985          "type": "transaction",
1986          "timestamp": 1619420405.0,
1987          "start_timestamp": 1619420400.0,
1988          "measurements": {
1989            "frames_slow": {
1990              "value": 1.0,
1991              "unit": "none",
1992            },
1993            "my_custom_measurement_1": {
1994              "value": 123.0,
1995              "unit": "none",
1996            },
1997            "my_custom_measurement_2": {
1998              "value": 789.0,
1999              "unit": "none",
2000            },
2001          },
2002          "_meta": {
2003            "measurements": {
2004              "": Meta(Some(MetaInner(
2005                err: [
2006                  [
2007                    "invalid_data",
2008                    {
2009                      "reason": "Too many measurements: my_custom_measurement_3",
2010                    },
2011                  ],
2012                ],
2013                val: Some({
2014                  "my_custom_measurement_3": {
2015                    "unit": "none",
2016                    "value": 456.0,
2017                  },
2018                }),
2019              ))),
2020            },
2021          },
2022        }
2023        "###);
2024    }
2025
2026    #[test]
2027    fn test_normalize_units() {
2028        let mut measurements = Annotated::<Measurements>::from_json(
2029            r#"{
2030                "fcp": {"value": 1.1},
2031                "stall_count": {"value": 3.3},
2032                "foo": {"value": 8.8}
2033            }"#,
2034        )
2035        .unwrap()
2036        .into_value()
2037        .unwrap();
2038        insta::assert_debug_snapshot!(measurements, @r###"
2039        Measurements(
2040            {
2041                "fcp": Measurement {
2042                    value: 1.1,
2043                    unit: ~,
2044                },
2045                "foo": Measurement {
2046                    value: 8.8,
2047                    unit: ~,
2048                },
2049                "stall_count": Measurement {
2050                    value: 3.3,
2051                    unit: ~,
2052                },
2053            },
2054        )
2055        "###);
2056        normalize_units(&mut measurements);
2057        insta::assert_debug_snapshot!(measurements, @r###"
2058        Measurements(
2059            {
2060                "fcp": Measurement {
2061                    value: 1.1,
2062                    unit: Duration(
2063                        MilliSecond,
2064                    ),
2065                },
2066                "foo": Measurement {
2067                    value: 8.8,
2068                    unit: None,
2069                },
2070                "stall_count": Measurement {
2071                    value: 3.3,
2072                    unit: None,
2073                },
2074            },
2075        )
2076        "###);
2077    }
2078
2079    #[test]
2080    fn test_normalize_security_report() {
2081        let mut event = Event {
2082            csp: Annotated::from(Csp::default()),
2083            ..Default::default()
2084        };
2085        let ipaddr = IpAddr("213.164.1.114".to_owned());
2086
2087        let client_ip = Some(&ipaddr);
2088
2089        let user_agent = RawUserAgentInfo {
2090            user_agent: Some(
2091                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0",
2092            ),
2093            client_hints: ClientHints {
2094                sec_ch_ua_platform: Some("macOS"),
2095                sec_ch_ua_platform_version: Some("13.2.0"),
2096                sec_ch_ua: Some(
2097                    r#""Chromium";v="110", "Not A(Brand";v="24", "Google Chrome";v="110""#,
2098                ),
2099                sec_ch_ua_model: Some("some model"),
2100            },
2101        };
2102
2103        // This call should fill the event headers with info from the user_agent which is
2104        // tested below.
2105        normalize_security_report(&mut event, client_ip, &user_agent);
2106
2107        let headers = event
2108            .request
2109            .value_mut()
2110            .get_or_insert_with(Request::default)
2111            .headers
2112            .value_mut()
2113            .get_or_insert_with(Headers::default);
2114
2115        assert_eq!(
2116            event.user.value().unwrap().ip_address,
2117            Annotated::from(ipaddr)
2118        );
2119        assert_eq!(
2120            headers.get_header(RawUserAgentInfo::USER_AGENT),
2121            user_agent.user_agent
2122        );
2123        assert_eq!(
2124            headers.get_header(ClientHints::SEC_CH_UA),
2125            user_agent.client_hints.sec_ch_ua,
2126        );
2127        assert_eq!(
2128            headers.get_header(ClientHints::SEC_CH_UA_MODEL),
2129            user_agent.client_hints.sec_ch_ua_model,
2130        );
2131        assert_eq!(
2132            headers.get_header(ClientHints::SEC_CH_UA_PLATFORM),
2133            user_agent.client_hints.sec_ch_ua_platform,
2134        );
2135        assert_eq!(
2136            headers.get_header(ClientHints::SEC_CH_UA_PLATFORM_VERSION),
2137            user_agent.client_hints.sec_ch_ua_platform_version,
2138        );
2139
2140        assert!(
2141            std::mem::size_of_val(&ClientHints::<&str>::default()) == 64,
2142            "If you add new fields, update the test accordingly"
2143        );
2144    }
2145
2146    #[test]
2147    fn test_no_device_class() {
2148        let mut event = Event {
2149            ..Default::default()
2150        };
2151        normalize_device_class(&mut event);
2152        let tags = &event.tags.value_mut().get_or_insert_with(Tags::default).0;
2153        assert_eq!(None, tags.get("device_class"));
2154    }
2155
2156    #[test]
2157    fn test_apple_low_device_class() {
2158        let mut event = Event {
2159            contexts: {
2160                let mut contexts = Contexts::new();
2161                contexts.add(DeviceContext {
2162                    family: "iPhone".to_owned().into(),
2163                    model: "iPhone8,4".to_owned().into(),
2164                    ..Default::default()
2165                });
2166                Annotated::new(contexts)
2167            },
2168            ..Default::default()
2169        };
2170        normalize_device_class(&mut event);
2171        assert_debug_snapshot!(event.tags, @r###"
2172        Tags(
2173            PairList(
2174                [
2175                    TagEntry(
2176                        "device.class",
2177                        "1",
2178                    ),
2179                ],
2180            ),
2181        )
2182        "###);
2183    }
2184
2185    #[test]
2186    fn test_apple_medium_device_class() {
2187        let mut event = Event {
2188            contexts: {
2189                let mut contexts = Contexts::new();
2190                contexts.add(DeviceContext {
2191                    family: "iPhone".to_owned().into(),
2192                    model: "iPhone12,8".to_owned().into(),
2193                    ..Default::default()
2194                });
2195                Annotated::new(contexts)
2196            },
2197            ..Default::default()
2198        };
2199        normalize_device_class(&mut event);
2200        assert_debug_snapshot!(event.tags, @r###"
2201        Tags(
2202            PairList(
2203                [
2204                    TagEntry(
2205                        "device.class",
2206                        "2",
2207                    ),
2208                ],
2209            ),
2210        )
2211        "###);
2212    }
2213
2214    #[test]
2215    fn test_android_low_device_class() {
2216        let mut event = Event {
2217            contexts: {
2218                let mut contexts = Contexts::new();
2219                contexts.add(DeviceContext {
2220                    family: "android".to_owned().into(),
2221                    processor_frequency: 1000.into(),
2222                    processor_count: 6.into(),
2223                    memory_size: (2 * 1024 * 1024 * 1024).into(),
2224                    ..Default::default()
2225                });
2226                Annotated::new(contexts)
2227            },
2228            ..Default::default()
2229        };
2230        normalize_device_class(&mut event);
2231        assert_debug_snapshot!(event.tags, @r###"
2232        Tags(
2233            PairList(
2234                [
2235                    TagEntry(
2236                        "device.class",
2237                        "1",
2238                    ),
2239                ],
2240            ),
2241        )
2242        "###);
2243    }
2244
2245    #[test]
2246    fn test_android_medium_device_class() {
2247        let mut event = Event {
2248            contexts: {
2249                let mut contexts = Contexts::new();
2250                contexts.add(DeviceContext {
2251                    family: "android".to_owned().into(),
2252                    processor_frequency: 2000.into(),
2253                    processor_count: 8.into(),
2254                    memory_size: (6 * 1024 * 1024 * 1024).into(),
2255                    ..Default::default()
2256                });
2257                Annotated::new(contexts)
2258            },
2259            ..Default::default()
2260        };
2261        normalize_device_class(&mut event);
2262        assert_debug_snapshot!(event.tags, @r###"
2263        Tags(
2264            PairList(
2265                [
2266                    TagEntry(
2267                        "device.class",
2268                        "2",
2269                    ),
2270                ],
2271            ),
2272        )
2273        "###);
2274    }
2275
2276    #[test]
2277    fn test_android_high_device_class() {
2278        let mut event = Event {
2279            contexts: {
2280                let mut contexts = Contexts::new();
2281                contexts.add(DeviceContext {
2282                    family: "android".to_owned().into(),
2283                    processor_frequency: 2500.into(),
2284                    processor_count: 8.into(),
2285                    memory_size: (6 * 1024 * 1024 * 1024).into(),
2286                    ..Default::default()
2287                });
2288                Annotated::new(contexts)
2289            },
2290            ..Default::default()
2291        };
2292        normalize_device_class(&mut event);
2293        assert_debug_snapshot!(event.tags, @r###"
2294        Tags(
2295            PairList(
2296                [
2297                    TagEntry(
2298                        "device.class",
2299                        "3",
2300                    ),
2301                ],
2302            ),
2303        )
2304        "###);
2305    }
2306
2307    #[test]
2308    fn test_keeps_valid_measurement() {
2309        let name = "lcp";
2310        let measurement = Measurement {
2311            value: Annotated::new(420.69.try_into().unwrap()),
2312            unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2313        };
2314
2315        assert!(!is_measurement_dropped(name, measurement));
2316    }
2317
2318    #[test]
2319    fn test_drops_too_long_measurement_names() {
2320        let name = "lcpppppppppppppppppppppppppppp";
2321        let measurement = Measurement {
2322            value: Annotated::new(420.69.try_into().unwrap()),
2323            unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2324        };
2325
2326        assert!(is_measurement_dropped(name, measurement));
2327    }
2328
2329    #[test]
2330    fn test_drops_measurements_with_invalid_characters() {
2331        let name = "i æm frøm nørwåy";
2332        let measurement = Measurement {
2333            value: Annotated::new(420.69.try_into().unwrap()),
2334            unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2335        };
2336
2337        assert!(is_measurement_dropped(name, measurement));
2338    }
2339
2340    fn is_measurement_dropped(name: &str, measurement: Measurement) -> bool {
2341        let max_name_and_unit_len = Some(30);
2342
2343        let mut measurements: BTreeMap<String, Annotated<Measurement>> = Object::new();
2344        measurements.insert(name.to_owned(), Annotated::new(measurement));
2345
2346        let mut measurements = Measurements(measurements);
2347        let mut meta = Meta::default();
2348        let measurements_config = MeasurementsConfig {
2349            max_custom_measurements: 1,
2350            ..Default::default()
2351        };
2352
2353        let dynamic_config = CombinedMeasurementsConfig::new(Some(&measurements_config), None);
2354
2355        // Just for clarity.
2356        // Checks that there is 1 measurement before processing.
2357        assert_eq!(measurements.len(), 1);
2358
2359        remove_invalid_measurements(
2360            &mut measurements,
2361            &mut meta,
2362            dynamic_config,
2363            max_name_and_unit_len,
2364        );
2365
2366        // Checks whether the measurement is dropped.
2367        measurements.is_empty()
2368    }
2369
2370    #[test]
2371    fn test_custom_measurements_not_dropped() {
2372        let mut measurements = Measurements(BTreeMap::from([(
2373            "custom_measurement".to_owned(),
2374            Annotated::new(Measurement {
2375                value: Annotated::new(42.0.try_into().unwrap()),
2376                unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
2377            }),
2378        )]));
2379
2380        let original = measurements.clone();
2381        remove_invalid_measurements(
2382            &mut measurements,
2383            &mut Meta::default(),
2384            CombinedMeasurementsConfig::new(None, None),
2385            Some(30),
2386        );
2387
2388        assert_eq!(original, measurements);
2389    }
2390
2391    #[test]
2392    fn test_normalize_app_start_measurements_does_not_add_measurements() {
2393        let mut measurements = Annotated::<Measurements>::from_json(r###"{}"###)
2394            .unwrap()
2395            .into_value()
2396            .unwrap();
2397        insta::assert_debug_snapshot!(measurements, @r###"
2398        Measurements(
2399            {},
2400        )
2401        "###);
2402        normalize_app_start_measurements(&mut measurements);
2403        insta::assert_debug_snapshot!(measurements, @r###"
2404        Measurements(
2405            {},
2406        )
2407        "###);
2408    }
2409
2410    #[test]
2411    fn test_normalize_app_start_cold_measurements() {
2412        let mut measurements =
2413            Annotated::<Measurements>::from_json(r#"{"app.start.cold": {"value": 1.1}}"#)
2414                .unwrap()
2415                .into_value()
2416                .unwrap();
2417        insta::assert_debug_snapshot!(measurements, @r###"
2418        Measurements(
2419            {
2420                "app.start.cold": Measurement {
2421                    value: 1.1,
2422                    unit: ~,
2423                },
2424            },
2425        )
2426        "###);
2427        normalize_app_start_measurements(&mut measurements);
2428        insta::assert_debug_snapshot!(measurements, @r###"
2429        Measurements(
2430            {
2431                "app_start_cold": Measurement {
2432                    value: 1.1,
2433                    unit: ~,
2434                },
2435            },
2436        )
2437        "###);
2438    }
2439
2440    #[test]
2441    fn test_normalize_app_start_warm_measurements() {
2442        let mut measurements =
2443            Annotated::<Measurements>::from_json(r#"{"app.start.warm": {"value": 1.1}}"#)
2444                .unwrap()
2445                .into_value()
2446                .unwrap();
2447        insta::assert_debug_snapshot!(measurements, @r###"
2448        Measurements(
2449            {
2450                "app.start.warm": Measurement {
2451                    value: 1.1,
2452                    unit: ~,
2453                },
2454            },
2455        )
2456        "###);
2457        normalize_app_start_measurements(&mut measurements);
2458        insta::assert_debug_snapshot!(measurements, @r###"
2459        Measurements(
2460            {
2461                "app_start_warm": Measurement {
2462                    value: 1.1,
2463                    unit: ~,
2464                },
2465            },
2466        )
2467        "###);
2468    }
2469
2470    #[test]
2471    fn test_ai_legacy_measurements() {
2472        let json = r#"
2473            {
2474                "spans": [
2475                    {
2476                        "timestamp": 1702474613.0495,
2477                        "start_timestamp": 1702474613.0175,
2478                        "description": "OpenAI ",
2479                        "op": "ai.chat_completions.openai",
2480                        "span_id": "9c01bd820a083e63",
2481                        "parent_span_id": "a1e13f3f06239d69",
2482                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2483                        "measurements": {
2484                            "ai_prompt_tokens_used": {
2485                                "value": 1000
2486                            },
2487                            "ai_completion_tokens_used": {
2488                                "value": 2000
2489                            }
2490                        },
2491                        "data": {
2492                            "gen_ai.request.model": "claude-2.1"
2493                        }
2494                    },
2495                    {
2496                        "timestamp": 1702474613.0495,
2497                        "start_timestamp": 1702474613.0175,
2498                        "description": "OpenAI ",
2499                        "op": "ai.chat_completions.openai",
2500                        "span_id": "ac01bd820a083e63",
2501                        "parent_span_id": "a1e13f3f06239d69",
2502                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2503                        "measurements": {
2504                            "ai_prompt_tokens_used": {
2505                                "value": 1000
2506                            },
2507                            "ai_completion_tokens_used": {
2508                                "value": 2000
2509                            }
2510                        },
2511                        "data": {
2512                            "gen_ai.request.model": "gpt4-21-04"
2513                        }
2514                    }
2515                ]
2516            }
2517        "#;
2518
2519        let mut event = Annotated::<Event>::from_json(json).unwrap();
2520
2521        normalize_event(
2522            &mut event,
2523            &NormalizationConfig {
2524                ai_model_metadata: Some(&ModelMetadata {
2525                    version: 1,
2526                    models: HashMap::from([
2527                        (
2528                            Pattern::new("claude-2.1").unwrap(),
2529                            ModelMetadataEntry {
2530                                costs: Some(ModelCostV2 {
2531                                    input_per_token: 0.01,
2532                                    output_per_token: 0.02,
2533                                    output_reasoning_per_token: 0.03,
2534                                    input_cached_per_token: 0.0,
2535                                    input_cache_write_per_token: 0.0,
2536                                }),
2537                                context_size: None,
2538                            },
2539                        ),
2540                        (
2541                            Pattern::new("gpt4-21-04").unwrap(),
2542                            ModelMetadataEntry {
2543                                costs: Some(ModelCostV2 {
2544                                    input_per_token: 0.02,
2545                                    output_per_token: 0.03,
2546                                    output_reasoning_per_token: 0.04,
2547                                    input_cached_per_token: 0.0,
2548                                    input_cache_write_per_token: 0.0,
2549                                }),
2550                                context_size: None,
2551                            },
2552                        ),
2553                    ]),
2554                }),
2555                ..NormalizationConfig::default()
2556            },
2557        );
2558
2559        let [span1, span2] = collect_span_data(event);
2560
2561        assert_annotated_snapshot!(span1, @r#"
2562        {
2563          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2564          "gen_ai.cost.cache_read.input_tokens": 0.0,
2565          "gen_ai.cost.input_tokens": 10.0,
2566          "gen_ai.cost.output_tokens": 40.0,
2567          "gen_ai.cost.reasoning.output_tokens": 0.0,
2568          "gen_ai.cost.total_tokens": 50.0,
2569          "gen_ai.operation.type": "ai_client",
2570          "gen_ai.request.model": "claude-2.1",
2571          "gen_ai.response.model": "claude-2.1",
2572          "gen_ai.response.tokens_per_second": 62500.0,
2573          "gen_ai.usage.input_tokens": 1000.0,
2574          "gen_ai.usage.output_tokens": 2000.0,
2575          "gen_ai.usage.total_tokens": 3000.0
2576        }
2577        "#);
2578        assert_annotated_snapshot!(span2, @r#"
2579        {
2580          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2581          "gen_ai.cost.cache_read.input_tokens": 0.0,
2582          "gen_ai.cost.input_tokens": 20.0,
2583          "gen_ai.cost.output_tokens": 60.0,
2584          "gen_ai.cost.reasoning.output_tokens": 0.0,
2585          "gen_ai.cost.total_tokens": 80.0,
2586          "gen_ai.operation.type": "ai_client",
2587          "gen_ai.request.model": "gpt4-21-04",
2588          "gen_ai.response.model": "gpt4-21-04",
2589          "gen_ai.response.tokens_per_second": 62500.0,
2590          "gen_ai.usage.input_tokens": 1000.0,
2591          "gen_ai.usage.output_tokens": 2000.0,
2592          "gen_ai.usage.total_tokens": 3000.0
2593        }
2594        "#);
2595    }
2596
2597    #[test]
2598    fn test_ai_data() {
2599        let json = r#"
2600            {
2601                "spans": [
2602                    {
2603                        "timestamp": 1702474614.0175,
2604                        "start_timestamp": 1702474613.0175,
2605                        "description": "OpenAI ",
2606                        "op": "gen_ai.chat_completions.openai",
2607                        "span_id": "9c01bd820a083e63",
2608                        "parent_span_id": "a1e13f3f06239d69",
2609                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2610                        "data": {
2611                            "gen_ai.usage.input_tokens": 1000,
2612                            "gen_ai.usage.output_tokens": 2000,
2613                            "gen_ai.usage.reasoning.output_tokens": 1000,
2614                            "gen_ai.usage.cache_read.input_tokens": 500,
2615                            "gen_ai.request.model": "claude-2.1"
2616                        }
2617                    },
2618                    {
2619                        "timestamp": 1702474614.0175,
2620                        "start_timestamp": 1702474613.0175,
2621                        "description": "OpenAI ",
2622                        "op": "gen_ai.chat_completions.openai",
2623                        "span_id": "ac01bd820a083e63",
2624                        "parent_span_id": "a1e13f3f06239d69",
2625                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2626                        "data": {
2627                            "gen_ai.usage.input_tokens": 1000,
2628                            "gen_ai.usage.output_tokens": 2000,
2629                            "gen_ai.request.model": "gpt4-21-04"
2630                        }
2631                    },
2632                    {
2633                        "timestamp": 1702474614.0175,
2634                        "start_timestamp": 1702474613.0175,
2635                        "description": "OpenAI ",
2636                        "op": "gen_ai.chat_completions.openai",
2637                        "span_id": "ac01bd820a083e63",
2638                        "parent_span_id": "a1e13f3f06239d69",
2639                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2640                        "data": {
2641                            "gen_ai.usage.input_tokens": 1000,
2642                            "gen_ai.usage.output_tokens": 2000,
2643                            "gen_ai.response.model": "gpt4-21-04"
2644                        }
2645                    }
2646                ]
2647            }
2648        "#;
2649
2650        let mut event = Annotated::<Event>::from_json(json).unwrap();
2651
2652        normalize_event(
2653            &mut event,
2654            &NormalizationConfig {
2655                ai_model_metadata: Some(&ModelMetadata {
2656                    version: 1,
2657                    models: HashMap::from([
2658                        (
2659                            Pattern::new("claude-2.1").unwrap(),
2660                            ModelMetadataEntry {
2661                                costs: Some(ModelCostV2 {
2662                                    input_per_token: 0.01,
2663                                    output_per_token: 0.02,
2664                                    output_reasoning_per_token: 0.03,
2665                                    input_cached_per_token: 0.04,
2666                                    input_cache_write_per_token: 0.0,
2667                                }),
2668                                context_size: None,
2669                            },
2670                        ),
2671                        (
2672                            Pattern::new("gpt4-21-04").unwrap(),
2673                            ModelMetadataEntry {
2674                                costs: Some(ModelCostV2 {
2675                                    input_per_token: 0.09,
2676                                    output_per_token: 0.05,
2677                                    output_reasoning_per_token: 0.0,
2678                                    input_cached_per_token: 0.0,
2679                                    input_cache_write_per_token: 0.0,
2680                                }),
2681                                context_size: None,
2682                            },
2683                        ),
2684                    ]),
2685                }),
2686                ..NormalizationConfig::default()
2687            },
2688        );
2689
2690        let [span1, span2, span3] = collect_span_data(event);
2691
2692        assert_annotated_snapshot!(span1, @r#"
2693        {
2694          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2695          "gen_ai.cost.cache_read.input_tokens": 20.0,
2696          "gen_ai.cost.input_tokens": 25.0,
2697          "gen_ai.cost.output_tokens": 50.0,
2698          "gen_ai.cost.reasoning.output_tokens": 30.0,
2699          "gen_ai.cost.total_tokens": 75.0,
2700          "gen_ai.operation.type": "ai_client",
2701          "gen_ai.request.model": "claude-2.1",
2702          "gen_ai.response.model": "claude-2.1",
2703          "gen_ai.response.tokens_per_second": 2000.0,
2704          "gen_ai.usage.cache_read.input_tokens": 500,
2705          "gen_ai.usage.input_tokens": 1000,
2706          "gen_ai.usage.output_tokens": 2000,
2707          "gen_ai.usage.reasoning.output_tokens": 1000,
2708          "gen_ai.usage.total_tokens": 3000.0
2709        }
2710        "#);
2711        assert_annotated_snapshot!(span2, @r#"
2712        {
2713          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2714          "gen_ai.cost.cache_read.input_tokens": 0.0,
2715          "gen_ai.cost.input_tokens": 90.0,
2716          "gen_ai.cost.output_tokens": 100.0,
2717          "gen_ai.cost.reasoning.output_tokens": 0.0,
2718          "gen_ai.cost.total_tokens": 190.0,
2719          "gen_ai.operation.type": "ai_client",
2720          "gen_ai.request.model": "gpt4-21-04",
2721          "gen_ai.response.model": "gpt4-21-04",
2722          "gen_ai.response.tokens_per_second": 2000.0,
2723          "gen_ai.usage.input_tokens": 1000,
2724          "gen_ai.usage.output_tokens": 2000,
2725          "gen_ai.usage.total_tokens": 3000.0
2726        }
2727        "#);
2728        assert_annotated_snapshot!(span3, @r#"
2729        {
2730          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2731          "gen_ai.cost.cache_read.input_tokens": 0.0,
2732          "gen_ai.cost.input_tokens": 90.0,
2733          "gen_ai.cost.output_tokens": 100.0,
2734          "gen_ai.cost.reasoning.output_tokens": 0.0,
2735          "gen_ai.cost.total_tokens": 190.0,
2736          "gen_ai.operation.type": "ai_client",
2737          "gen_ai.response.model": "gpt4-21-04",
2738          "gen_ai.response.tokens_per_second": 2000.0,
2739          "gen_ai.usage.input_tokens": 1000,
2740          "gen_ai.usage.output_tokens": 2000,
2741          "gen_ai.usage.total_tokens": 3000.0
2742        }
2743        "#);
2744    }
2745
2746    #[test]
2747    fn test_ai_data_with_no_tokens() {
2748        let json = r#"
2749            {
2750                "spans": [
2751                    {
2752                        "timestamp": 1702474613.0495,
2753                        "start_timestamp": 1702474613.0175,
2754                        "description": "OpenAI ",
2755                        "op": "gen_ai.invoke_agent",
2756                        "span_id": "9c01bd820a083e63",
2757                        "parent_span_id": "a1e13f3f06239d69",
2758                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2759                        "data": {
2760                            "gen_ai.request.model": "claude-2.1"
2761                        }
2762                    }
2763                ]
2764            }
2765        "#;
2766
2767        let mut event = Annotated::<Event>::from_json(json).unwrap();
2768
2769        normalize_event(
2770            &mut event,
2771            &NormalizationConfig {
2772                ai_model_metadata: Some(&ModelMetadata {
2773                    version: 1,
2774                    models: HashMap::from([(
2775                        Pattern::new("claude-2.1").unwrap(),
2776                        ModelMetadataEntry {
2777                            costs: Some(ModelCostV2 {
2778                                input_per_token: 0.01,
2779                                output_per_token: 0.02,
2780                                output_reasoning_per_token: 0.03,
2781                                input_cached_per_token: 0.0,
2782                                input_cache_write_per_token: 0.0,
2783                            }),
2784                            context_size: None,
2785                        },
2786                    )]),
2787                }),
2788                ..NormalizationConfig::default()
2789            },
2790        );
2791
2792        let [span] = collect_span_data(event);
2793
2794        assert_annotated_snapshot!(span, @r#"
2795        {
2796          "gen_ai.operation.type": "agent",
2797          "gen_ai.request.model": "claude-2.1",
2798          "gen_ai.response.model": "claude-2.1"
2799        }
2800        "#);
2801    }
2802
2803    #[test]
2804    fn test_ai_data_with_ai_op_prefix() {
2805        let json = r#"
2806            {
2807                "spans": [
2808                    {
2809                        "timestamp": 1702474613.0495,
2810                        "start_timestamp": 1702474613.0175,
2811                        "description": "OpenAI ",
2812                        "op": "ai.chat_completions.openai",
2813                        "span_id": "9c01bd820a083e63",
2814                        "parent_span_id": "a1e13f3f06239d69",
2815                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2816                        "data": {
2817                            "gen_ai.usage.input_tokens": 1000,
2818                            "gen_ai.usage.output_tokens": 2000,
2819                            "gen_ai.usage.reasoning.output_tokens": 1000,
2820                            "gen_ai.usage.cache_read.input_tokens": 500,
2821                            "gen_ai.request.model": "claude-2.1"
2822                        }
2823                    },
2824                    {
2825                        "timestamp": 1702474613.0495,
2826                        "start_timestamp": 1702474613.0175,
2827                        "description": "OpenAI ",
2828                        "op": "ai.chat_completions.openai",
2829                        "span_id": "ac01bd820a083e63",
2830                        "parent_span_id": "a1e13f3f06239d69",
2831                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2832                        "data": {
2833                            "gen_ai.usage.input_tokens": 1000,
2834                            "gen_ai.usage.output_tokens": 2000,
2835                            "gen_ai.request.model": "gpt4-21-04"
2836                        }
2837                    }
2838                ]
2839            }
2840        "#;
2841
2842        let mut event = Annotated::<Event>::from_json(json).unwrap();
2843
2844        normalize_event(
2845            &mut event,
2846            &NormalizationConfig {
2847                ai_model_metadata: Some(&ModelMetadata {
2848                    version: 1,
2849                    models: HashMap::from([
2850                        (
2851                            Pattern::new("claude-2.1").unwrap(),
2852                            ModelMetadataEntry {
2853                                costs: Some(ModelCostV2 {
2854                                    input_per_token: 0.01,
2855                                    output_per_token: 0.02,
2856                                    output_reasoning_per_token: 0.0,
2857                                    input_cached_per_token: 0.04,
2858                                    input_cache_write_per_token: 0.0,
2859                                }),
2860                                context_size: None,
2861                            },
2862                        ),
2863                        (
2864                            Pattern::new("gpt4-21-04").unwrap(),
2865                            ModelMetadataEntry {
2866                                costs: Some(ModelCostV2 {
2867                                    input_per_token: 0.09,
2868                                    output_per_token: 0.05,
2869                                    output_reasoning_per_token: 0.06,
2870                                    input_cached_per_token: 0.0,
2871                                    input_cache_write_per_token: 0.0,
2872                                }),
2873                                context_size: None,
2874                            },
2875                        ),
2876                    ]),
2877                }),
2878                ..NormalizationConfig::default()
2879            },
2880        );
2881
2882        let [span1, span2] = collect_span_data(event);
2883
2884        assert_annotated_snapshot!(span1, @r#"
2885        {
2886          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2887          "gen_ai.cost.cache_read.input_tokens": 20.0,
2888          "gen_ai.cost.input_tokens": 25.0,
2889          "gen_ai.cost.output_tokens": 40.0,
2890          "gen_ai.cost.reasoning.output_tokens": 20.0,
2891          "gen_ai.cost.total_tokens": 65.0,
2892          "gen_ai.operation.type": "ai_client",
2893          "gen_ai.request.model": "claude-2.1",
2894          "gen_ai.response.model": "claude-2.1",
2895          "gen_ai.response.tokens_per_second": 62500.0,
2896          "gen_ai.usage.cache_read.input_tokens": 500,
2897          "gen_ai.usage.input_tokens": 1000,
2898          "gen_ai.usage.output_tokens": 2000,
2899          "gen_ai.usage.reasoning.output_tokens": 1000,
2900          "gen_ai.usage.total_tokens": 3000.0
2901        }
2902        "#);
2903        assert_annotated_snapshot!(span2, @r#"
2904        {
2905          "gen_ai.cost.cache_creation.input_tokens": 0.0,
2906          "gen_ai.cost.cache_read.input_tokens": 0.0,
2907          "gen_ai.cost.input_tokens": 90.0,
2908          "gen_ai.cost.output_tokens": 100.0,
2909          "gen_ai.cost.reasoning.output_tokens": 0.0,
2910          "gen_ai.cost.total_tokens": 190.0,
2911          "gen_ai.operation.type": "ai_client",
2912          "gen_ai.request.model": "gpt4-21-04",
2913          "gen_ai.response.model": "gpt4-21-04",
2914          "gen_ai.response.tokens_per_second": 62500.0,
2915          "gen_ai.usage.input_tokens": 1000,
2916          "gen_ai.usage.output_tokens": 2000,
2917          "gen_ai.usage.total_tokens": 3000.0
2918        }
2919        "#);
2920    }
2921
2922    #[test]
2923    fn test_ai_response_tokens_per_second_no_output_tokens() {
2924        let json = r#"
2925            {
2926                "spans": [
2927                    {
2928                        "timestamp": 1702474614.0175,
2929                        "start_timestamp": 1702474613.0175,
2930                        "op": "gen_ai.chat_completions",
2931                        "span_id": "9c01bd820a083e63",
2932                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2933                        "data": {
2934                            "gen_ai.usage.input_tokens": 500
2935                        }
2936                    }
2937                ]
2938            }
2939        "#;
2940
2941        let mut event = Annotated::<Event>::from_json(json).unwrap();
2942
2943        normalize_event(
2944            &mut event,
2945            &NormalizationConfig {
2946                ai_model_metadata: Some(&ModelMetadata {
2947                    version: 1,
2948                    models: HashMap::new(),
2949                }),
2950                ..NormalizationConfig::default()
2951            },
2952        );
2953
2954        let [span] = collect_span_data(event);
2955
2956        // Should not set response_tokens_per_second when there are no output tokens
2957        assert_annotated_snapshot!(span, @r#"
2958        {
2959          "gen_ai.operation.type": "ai_client",
2960          "gen_ai.usage.input_tokens": 500,
2961          "gen_ai.usage.total_tokens": 500.0
2962        }
2963        "#);
2964    }
2965
2966    #[test]
2967    fn test_ai_response_tokens_per_second_zero_duration() {
2968        let json = r#"
2969            {
2970                "spans": [
2971                    {
2972                        "timestamp": 1702474613.0175,
2973                        "start_timestamp": 1702474613.0175,
2974                        "op": "gen_ai.chat_completions",
2975                        "span_id": "9c01bd820a083e63",
2976                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2977                        "data": {
2978                            "gen_ai.usage.output_tokens": 1000
2979                        }
2980                    }
2981                ]
2982            }
2983        "#;
2984
2985        let mut event = Annotated::<Event>::from_json(json).unwrap();
2986
2987        normalize_event(
2988            &mut event,
2989            &NormalizationConfig {
2990                ai_model_metadata: Some(&ModelMetadata {
2991                    version: 1,
2992                    models: HashMap::new(),
2993                }),
2994                ..NormalizationConfig::default()
2995            },
2996        );
2997
2998        let [span] = collect_span_data(event);
2999
3000        // Should not set response_tokens_per_second when duration is zero
3001        assert_annotated_snapshot!(span, @r#"
3002        {
3003          "gen_ai.operation.type": "ai_client",
3004          "gen_ai.usage.output_tokens": 1000,
3005          "gen_ai.usage.total_tokens": 1000.0
3006        }
3007        "#);
3008    }
3009
3010    #[test]
3011    fn test_ai_operation_type_mapping() {
3012        let json = r#"
3013            {
3014                "type": "transaction",
3015                "transaction": "test-transaction",
3016                "spans": [
3017                    {
3018                        "op": "gen_ai.chat",
3019                        "description": "AI chat completion",
3020                        "data": {}
3021                    },
3022                    {
3023                        "op": "gen_ai.handoff",
3024                        "description": "AI agent handoff",
3025                        "data": {}
3026                    },
3027                    {
3028                        "op": "gen_ai.unknown",
3029                        "description": "Unknown AI operation",
3030                        "data": {}
3031                    }
3032                ]
3033            }
3034        "#;
3035
3036        let mut event = Annotated::<Event>::from_json(json).unwrap();
3037
3038        normalize_event(&mut event, &NormalizationConfig::default());
3039
3040        let [span1, span2, span3] = collect_span_data(event);
3041
3042        assert_annotated_snapshot!(span1, @r#"
3043        {
3044          "gen_ai.operation.type": "ai_client"
3045        }
3046        "#);
3047        assert_annotated_snapshot!(span2, @r#"
3048        {
3049          "gen_ai.operation.type": "handoff"
3050        }
3051        "#);
3052        assert_annotated_snapshot!(span3, @r#"
3053        {
3054          "gen_ai.operation.type": "ai_client"
3055        }
3056        "#);
3057    }
3058
3059    #[test]
3060    fn test_apple_high_device_class() {
3061        let mut event = Event {
3062            contexts: {
3063                let mut contexts = Contexts::new();
3064                contexts.add(DeviceContext {
3065                    family: "iPhone".to_owned().into(),
3066                    model: "iPhone15,3".to_owned().into(),
3067                    ..Default::default()
3068                });
3069                Annotated::new(contexts)
3070            },
3071            ..Default::default()
3072        };
3073        normalize_device_class(&mut event);
3074        assert_debug_snapshot!(event.tags, @r###"
3075        Tags(
3076            PairList(
3077                [
3078                    TagEntry(
3079                        "device.class",
3080                        "3",
3081                    ),
3082                ],
3083            ),
3084        )
3085        "###);
3086    }
3087
3088    #[test]
3089    fn test_filter_mobile_outliers() {
3090        let mut measurements =
3091            Annotated::<Measurements>::from_json(r#"{"app_start_warm": {"value": 180001}}"#)
3092                .unwrap()
3093                .into_value()
3094                .unwrap();
3095        assert_eq!(measurements.len(), 1);
3096        filter_mobile_outliers(&mut measurements);
3097        assert_eq!(measurements.len(), 0);
3098    }
3099
3100    #[test]
3101    fn test_backfill_app_vitals_start_cold() {
3102        let json = r#"{
3103            "type": "transaction",
3104            "timestamp": "2021-04-26T08:00:05+0100",
3105            "start_timestamp": "2021-04-26T08:00:00+0100",
3106            "measurements": {"app_start_cold": {"value": 1234.0, "unit": "millisecond"}}
3107        }"#;
3108        let mut event = Annotated::<Event>::from_json(json)
3109            .unwrap()
3110            .into_value()
3111            .unwrap();
3112        backfill_app_vitals_start(&mut event);
3113        assert_debug_snapshot!(event.measurements, @r#"
3114        Measurements(
3115            {
3116                "app.vitals.start.value": Measurement {
3117                    value: 1234.0,
3118                    unit: Duration(
3119                        MilliSecond,
3120                    ),
3121                },
3122                "app_start_cold": Measurement {
3123                    value: 1234.0,
3124                    unit: Duration(
3125                        MilliSecond,
3126                    ),
3127                },
3128            },
3129        )
3130        "#);
3131        assert_debug_snapshot!(event.tags, @r#"
3132        Tags(
3133            PairList(
3134                [
3135                    TagEntry(
3136                        "app.vitals.start.type",
3137                        "cold",
3138                    ),
3139                ],
3140            ),
3141        )
3142        "#);
3143    }
3144
3145    #[test]
3146    fn test_backfill_app_vitals_start_warm() {
3147        let json = r#"{
3148            "type": "transaction",
3149            "timestamp": "2021-04-26T08:00:05+0100",
3150            "start_timestamp": "2021-04-26T08:00:00+0100",
3151            "measurements": {"app_start_warm": {"value": 567.0, "unit": "millisecond"}}
3152        }"#;
3153        let mut event = Annotated::<Event>::from_json(json)
3154            .unwrap()
3155            .into_value()
3156            .unwrap();
3157        backfill_app_vitals_start(&mut event);
3158        assert_debug_snapshot!(event.measurements, @r#"
3159        Measurements(
3160            {
3161                "app.vitals.start.value": Measurement {
3162                    value: 567.0,
3163                    unit: Duration(
3164                        MilliSecond,
3165                    ),
3166                },
3167                "app_start_warm": Measurement {
3168                    value: 567.0,
3169                    unit: Duration(
3170                        MilliSecond,
3171                    ),
3172                },
3173            },
3174        )
3175        "#);
3176        assert_debug_snapshot!(event.tags, @r#"
3177        Tags(
3178            PairList(
3179                [
3180                    TagEntry(
3181                        "app.vitals.start.type",
3182                        "warm",
3183                    ),
3184                ],
3185            ),
3186        )
3187        "#);
3188    }
3189
3190    #[test]
3191    fn test_backfill_app_vitals_start_cold_preferred_over_warm() {
3192        let json = r#"{
3193            "type": "transaction",
3194            "timestamp": "2021-04-26T08:00:05+0100",
3195            "start_timestamp": "2021-04-26T08:00:00+0100",
3196            "measurements": {
3197                "app_start_cold": {"value": 100.0, "unit": "millisecond"},
3198                "app_start_warm": {"value": 200.0, "unit": "millisecond"}
3199            }
3200        }"#;
3201        let mut event = Annotated::<Event>::from_json(json)
3202            .unwrap()
3203            .into_value()
3204            .unwrap();
3205        backfill_app_vitals_start(&mut event);
3206        assert_debug_snapshot!(event.measurements, @r#"
3207        Measurements(
3208            {
3209                "app.vitals.start.value": Measurement {
3210                    value: 100.0,
3211                    unit: Duration(
3212                        MilliSecond,
3213                    ),
3214                },
3215                "app_start_cold": Measurement {
3216                    value: 100.0,
3217                    unit: Duration(
3218                        MilliSecond,
3219                    ),
3220                },
3221                "app_start_warm": Measurement {
3222                    value: 200.0,
3223                    unit: Duration(
3224                        MilliSecond,
3225                    ),
3226                },
3227            },
3228        )
3229        "#);
3230        assert_debug_snapshot!(event.tags, @r#"
3231        Tags(
3232            PairList(
3233                [
3234                    TagEntry(
3235                        "app.vitals.start.type",
3236                        "cold",
3237                    ),
3238                ],
3239            ),
3240        )
3241        "#);
3242    }
3243
3244    #[test]
3245    fn test_backfill_app_vitals_start_no_app_start_noop() {
3246        let json = r#"{
3247            "type": "transaction",
3248            "timestamp": "2021-04-26T08:00:05+0100",
3249            "start_timestamp": "2021-04-26T08:00:00+0100",
3250            "measurements": {"lcp": {"value": 100.0}}
3251        }"#;
3252        let mut event = Annotated::<Event>::from_json(json)
3253            .unwrap()
3254            .into_value()
3255            .unwrap();
3256        backfill_app_vitals_start(&mut event);
3257        assert_debug_snapshot!(event.measurements, @r#"
3258        Measurements(
3259            {
3260                "lcp": Measurement {
3261                    value: 100.0,
3262                    unit: ~,
3263                },
3264            },
3265        )
3266        "#);
3267        assert_debug_snapshot!(event.tags, @"~");
3268    }
3269
3270    #[test]
3271    fn test_backfill_app_vitals_start_respects_outlier_filter() {
3272        let json = r#"{
3273            "type": "transaction",
3274            "timestamp": "2021-04-26T08:00:05+0100",
3275            "start_timestamp": "2021-04-26T08:00:00+0100",
3276            "measurements": {"app_start_cold": {"value": 180001.0, "unit": "millisecond"}}
3277        }"#;
3278        let mut event = Annotated::<Event>::from_json(json)
3279            .unwrap()
3280            .into_value()
3281            .unwrap();
3282        normalize_event_measurements(&mut event, None, None);
3283        backfill_app_vitals_start(&mut event);
3284        assert_debug_snapshot!(event.measurements, @"
3285        Measurements(
3286            {},
3287        )
3288        ");
3289        assert_debug_snapshot!(event.tags, @"~");
3290    }
3291
3292    #[test]
3293    fn test_backfill_app_vitals_start_non_transaction_payload_noop() {
3294        let json = r#"{
3295            "type": "error",
3296            "measurements": {
3297                "app_start_cold": {"value": 1234.0, "unit": "millisecond"}
3298            }
3299        }"#;
3300        let mut event = Annotated::<Event>::from_json(json)
3301            .unwrap()
3302            .into_value()
3303            .unwrap();
3304        backfill_app_vitals_start(&mut event);
3305        assert_debug_snapshot!(event.measurements, @r#"
3306        Measurements(
3307            {
3308                "app_start_cold": Measurement {
3309                    value: 1234.0,
3310                    unit: Duration(
3311                        MilliSecond,
3312                    ),
3313                },
3314            },
3315        )
3316        "#);
3317        assert_debug_snapshot!(event.tags, @"~");
3318    }
3319
3320    #[test]
3321    fn test_backfill_app_vitals_start_does_not_overwrite_value() {
3322        let json = r#"{
3323            "type": "transaction",
3324            "timestamp": "2021-04-26T08:00:05+0100",
3325            "start_timestamp": "2021-04-26T08:00:00+0100",
3326            "measurements": {
3327                "app_start_cold": {"value": 100.0, "unit": "millisecond"},
3328                "app.vitals.start.value": {"value": 999.0, "unit": "millisecond"}
3329            }
3330        }"#;
3331        let mut event = Annotated::<Event>::from_json(json)
3332            .unwrap()
3333            .into_value()
3334            .unwrap();
3335        backfill_app_vitals_start(&mut event);
3336        assert_debug_snapshot!(event.measurements, @r#"
3337        Measurements(
3338            {
3339                "app.vitals.start.value": Measurement {
3340                    value: 999.0,
3341                    unit: Duration(
3342                        MilliSecond,
3343                    ),
3344                },
3345                "app_start_cold": Measurement {
3346                    value: 100.0,
3347                    unit: Duration(
3348                        MilliSecond,
3349                    ),
3350                },
3351            },
3352        )
3353        "#);
3354        assert_debug_snapshot!(event.tags, @"~");
3355    }
3356
3357    #[test]
3358    fn test_backfill_app_vitals_start_does_not_overwrite_type() {
3359        let json = r#"{
3360            "type": "transaction",
3361            "timestamp": "2021-04-26T08:00:05+0100",
3362            "start_timestamp": "2021-04-26T08:00:00+0100",
3363            "measurements": {"app_start_cold": {"value": 100.0, "unit": "millisecond"}}
3364        }"#;
3365        let mut event = Annotated::<Event>::from_json(json)
3366            .unwrap()
3367            .into_value()
3368            .unwrap();
3369        event
3370            .tags
3371            .value_mut()
3372            .get_or_insert_with(Tags::default)
3373            .0
3374            .insert(
3375                String::from(APP__VITALS__START__TYPE),
3376                Annotated::new("warm".to_owned()),
3377            );
3378
3379        backfill_app_vitals_start(&mut event);
3380
3381        assert_debug_snapshot!(event.measurements, @r#"
3382        Measurements(
3383            {
3384                "app_start_cold": Measurement {
3385                    value: 100.0,
3386                    unit: Duration(
3387                        MilliSecond,
3388                    ),
3389                },
3390            },
3391        )
3392        "#);
3393        assert_debug_snapshot!(event.tags, @r#"
3394        Tags(
3395            PairList(
3396                [
3397                    TagEntry(
3398                        "app.vitals.start.type",
3399                        "warm",
3400                    ),
3401                ],
3402            ),
3403        )
3404        "#);
3405    }
3406
3407    #[test]
3408    fn test_backfill_app_vitals_start_invalid_unit_noop() {
3409        let json = r#"{
3410            "type": "transaction",
3411            "timestamp": "2021-04-26T08:00:05+0100",
3412            "start_timestamp": "2021-04-26T08:00:00+0100",
3413            "measurements": {"app_start_cold": {"value": 1.5, "unit": "second"}}
3414        }"#;
3415        let mut event = Annotated::<Event>::from_json(json)
3416            .unwrap()
3417            .into_value()
3418            .unwrap();
3419        backfill_app_vitals_start(&mut event);
3420        assert_debug_snapshot!(event.measurements, @r#"
3421        Measurements(
3422            {
3423                "app_start_cold": Measurement {
3424                    value: 1.5,
3425                    unit: Duration(
3426                        Second,
3427                    ),
3428                },
3429            },
3430        )
3431        "#);
3432        assert_debug_snapshot!(event.tags, @"~");
3433    }
3434
3435    #[test]
3436    fn test_backfill_app_vitals_start_screen_from_legacy_measurement() {
3437        let json = r#"{
3438            "type": "transaction",
3439            "transaction": "MainActivity",
3440            "contexts": {
3441                "trace": {
3442                    "op": "ui.load"
3443                }
3444            },
3445            "measurements": {
3446                "app_start_cold": {
3447                    "value": 1234.0,
3448                    "unit": "millisecond"
3449                }
3450            }
3451        }"#;
3452        let mut event = Annotated::<Event>::from_json(json)
3453            .unwrap()
3454            .into_value()
3455            .unwrap();
3456
3457        backfill_app_vitals_start(&mut event);
3458
3459        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3460        {
3461          "app.vitals.start.screen": "MainActivity"
3462        }
3463        "#);
3464    }
3465
3466    #[test]
3467    fn test_backfill_app_vitals_start_screen_from_dotted_measurement() {
3468        let mut event = app_vitals_start_screen_event(
3469            "transaction",
3470            Some("SettingsActivity"),
3471            "ui.load",
3472            Some(APP__VITALS__START__WARM__VALUE),
3473            None,
3474        );
3475
3476        backfill_app_vitals_start(&mut event);
3477
3478        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3479        {
3480          "app.vitals.start.screen": "SettingsActivity"
3481        }
3482        "#);
3483    }
3484
3485    #[test]
3486    fn test_backfill_app_vitals_start_screen_from_start_value_measurement() {
3487        let mut event = app_vitals_start_screen_event(
3488            "transaction",
3489            Some("ProfileActivity"),
3490            "ui.load",
3491            Some(APP__VITALS__START__VALUE),
3492            None,
3493        );
3494
3495        backfill_app_vitals_start(&mut event);
3496
3497        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3498        {
3499          "app.vitals.start.screen": "ProfileActivity"
3500        }
3501        "#);
3502    }
3503
3504    #[test]
3505    fn test_backfill_app_vitals_start_screen_requires_ui_load() {
3506        let mut event = app_vitals_start_screen_event(
3507            "transaction",
3508            Some("MainActivity"),
3509            "navigation",
3510            Some("app_start_cold"),
3511            None,
3512        );
3513
3514        backfill_app_vitals_start(&mut event);
3515
3516        assert_annotated_snapshot!(trace_context_data(&event), @"{}");
3517    }
3518
3519    #[test]
3520    fn test_backfill_app_vitals_start_screen_requires_app_start_measurement() {
3521        let mut event = app_vitals_start_screen_event(
3522            "transaction",
3523            Some("MainActivity"),
3524            "ui.load",
3525            None,
3526            None,
3527        );
3528
3529        backfill_app_vitals_start(&mut event);
3530
3531        assert_annotated_snapshot!(trace_context_data(&event), @"{}");
3532    }
3533
3534    #[test]
3535    fn test_backfill_app_vitals_start_screen_only_requires_measurement_key() {
3536        let json = r#"{
3537            "type": "transaction",
3538            "transaction": "MainActivity",
3539            "contexts": {
3540                "trace": {
3541                    "op": "ui.load"
3542                }
3543            },
3544            "measurements": {
3545                "app_start_cold": {
3546                    "unit": "millisecond"
3547                }
3548            }
3549        }"#;
3550        let mut event = Annotated::<Event>::from_json(json)
3551            .unwrap()
3552            .into_value()
3553            .unwrap();
3554
3555        backfill_app_vitals_start(&mut event);
3556
3557        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3558        {
3559          "app.vitals.start.screen": "MainActivity"
3560        }
3561        "#);
3562    }
3563
3564    #[test]
3565    fn test_backfill_app_vitals_start_screen_preserves_existing_value() {
3566        let mut event = app_vitals_start_screen_event(
3567            "transaction",
3568            Some("MainActivity"),
3569            "ui.load",
3570            Some("app_start_cold"),
3571            Some("SDKScreen"),
3572        );
3573
3574        backfill_app_vitals_start(&mut event);
3575
3576        assert_annotated_snapshot!(trace_context_data(&event), @r#"
3577        {
3578          "app.vitals.start.screen": "SDKScreen"
3579        }
3580        "#);
3581    }
3582
3583    #[test]
3584    fn test_backfill_app_vitals_start_screen_requires_transaction_name() {
3585        let mut event = app_vitals_start_screen_event(
3586            "transaction",
3587            Some("<unlabeled transaction>"),
3588            "ui.load",
3589            Some("app_start_cold"),
3590            None,
3591        );
3592
3593        backfill_app_vitals_start(&mut event);
3594
3595        assert_annotated_snapshot!(trace_context_data(&event), @"{}");
3596    }
3597
3598    #[test]
3599    fn test_computed_performance_score_transaction() {
3600        let json = r#"
3601        {
3602            "type": "transaction",
3603            "timestamp": "2021-04-26T08:00:05+0100",
3604            "start_timestamp": "2021-04-26T08:00:00+0100",
3605            "measurements": {
3606                "fid": {"value": 213, "unit": "millisecond"},
3607                "fcp": {"value": 1237, "unit": "millisecond"},
3608                "lcp": {"value": 6596, "unit": "millisecond"},
3609                "cls": {"value": 0.11}
3610            },
3611            "contexts": {
3612                "browser": {
3613                    "name": "Chrome",
3614                    "version": "120.1.1",
3615                    "type": "browser"
3616                }
3617            }
3618        }
3619        "#;
3620
3621        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3622
3623        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3624            "profiles": [
3625                {
3626                    "name": "Desktop",
3627                    "scoreComponents": [
3628                        {
3629                            "measurement": "fcp",
3630                            "weight": 0.15,
3631                            "p10": 900,
3632                            "p50": 1600
3633                        },
3634                        {
3635                            "measurement": "lcp",
3636                            "weight": 0.30,
3637                            "p10": 1200,
3638                            "p50": 2400
3639                        },
3640                        {
3641                            "measurement": "fid",
3642                            "weight": 0.30,
3643                            "p10": 100,
3644                            "p50": 300
3645                        },
3646                        {
3647                            "measurement": "cls",
3648                            "weight": 0.25,
3649                            "p10": 0.1,
3650                            "p50": 0.25
3651                        },
3652                        {
3653                            "measurement": "ttfb",
3654                            "weight": 0.0,
3655                            "p10": 0.2,
3656                            "p50": 0.4
3657                        },
3658                    ],
3659                    "condition": {
3660                        "op":"eq",
3661                        "name": "event.contexts.browser.name",
3662                        "value": "Chrome"
3663                    }
3664                }
3665            ]
3666        }))
3667        .unwrap();
3668
3669        normalize_performance_score(&mut event, Some(&performance_score));
3670
3671        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3672        {
3673          "type": "transaction",
3674          "timestamp": 1619420405.0,
3675          "start_timestamp": 1619420400.0,
3676          "contexts": {
3677            "browser": {
3678              "name": "Chrome",
3679              "version": "120.1.1",
3680              "type": "browser",
3681            },
3682          },
3683          "measurements": {
3684            "cls": {
3685              "value": 0.11,
3686            },
3687            "fcp": {
3688              "value": 1237.0,
3689              "unit": "millisecond",
3690            },
3691            "fid": {
3692              "value": 213.0,
3693              "unit": "millisecond",
3694            },
3695            "lcp": {
3696              "value": 6596.0,
3697              "unit": "millisecond",
3698            },
3699            "score.cls": {
3700              "value": 0.21864170607444863,
3701              "unit": "ratio",
3702            },
3703            "score.fcp": {
3704              "value": 0.10750855443790831,
3705              "unit": "ratio",
3706            },
3707            "score.fid": {
3708              "value": 0.19657361348282545,
3709              "unit": "ratio",
3710            },
3711            "score.lcp": {
3712              "value": 0.009238896571386584,
3713              "unit": "ratio",
3714            },
3715            "score.ratio.cls": {
3716              "value": 0.8745668242977945,
3717              "unit": "ratio",
3718            },
3719            "score.ratio.fcp": {
3720              "value": 0.7167236962527221,
3721              "unit": "ratio",
3722            },
3723            "score.ratio.fid": {
3724              "value": 0.6552453782760849,
3725              "unit": "ratio",
3726            },
3727            "score.ratio.lcp": {
3728              "value": 0.03079632190462195,
3729              "unit": "ratio",
3730            },
3731            "score.total": {
3732              "value": 0.531962770566569,
3733              "unit": "ratio",
3734            },
3735            "score.weight.cls": {
3736              "value": 0.25,
3737              "unit": "ratio",
3738            },
3739            "score.weight.fcp": {
3740              "value": 0.15,
3741              "unit": "ratio",
3742            },
3743            "score.weight.fid": {
3744              "value": 0.3,
3745              "unit": "ratio",
3746            },
3747            "score.weight.lcp": {
3748              "value": 0.3,
3749              "unit": "ratio",
3750            },
3751            "score.weight.ttfb": {
3752              "value": 0.0,
3753              "unit": "ratio",
3754            },
3755          },
3756        }
3757        "###);
3758    }
3759
3760    /// A version of `test_computed_performance_score_transaction` for
3761    /// V2 spans. Results are _mutatis mutandis_ the same.
3762    ///
3763    /// The `"condition"` on the profile is written as a disjunction,
3764    /// checking for the browser name in both `event.context` and in
3765    /// `span.attributes`.
3766    #[test]
3767    fn test_computed_performance_score_spanv2() {
3768        let json = r#"
3769        {
3770            "end_timestamp": "2021-04-26T08:00:05+0100",
3771            "start_timestamp": "2021-04-26T08:00:00+0100",
3772            "attributes": {
3773                "browser.name": {"value": "Chrome", "type": "string"},
3774                "browser.version": {"value": "120.1.1", "type": "string"},
3775                "fid": {"value": 213, "type": "double"},
3776                "browser.web_vital.fcp.value": {"value": 1237.0, "type": "double"},
3777                "lcp": {"value": 6596, "type": "double"},
3778                "browser.web_vital.cls.value": {"value": 0.11, "type": "double"}
3779            }
3780        }
3781        "#;
3782
3783        let mut span = Annotated::<SpanV2>::from_json(json).unwrap().0.unwrap();
3784
3785        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3786            "profiles": [
3787                {
3788                    "name": "Desktop",
3789                    "scoreComponents": [
3790                        {
3791                            "measurement": "fcp",
3792                            "weight": 0.15,
3793                            "p10": 900,
3794                            "p50": 1600
3795                        },
3796                        {
3797                            "measurement": "lcp",
3798                            "weight": 0.30,
3799                            "p10": 1200,
3800                            "p50": 2400
3801                        },
3802                        {
3803                            "measurement": "fid",
3804                            "weight": 0.30,
3805                            "p10": 100,
3806                            "p50": 300
3807                        },
3808                        {
3809                            "measurement": "cls",
3810                            "weight": 0.25,
3811                            "p10": 0.1,
3812                            "p50": 0.25
3813                        },
3814                        {
3815                            "measurement": "ttfb",
3816                            "weight": 0.0,
3817                            "p10": 0.2,
3818                            "p50": 0.4
3819                        },
3820                    ],
3821                    "condition": {
3822                        "op": "or",
3823                        "inner": [{
3824                            "op":"eq",
3825                            "name": "event.context.browser.name",
3826                            "value": "Chrome"
3827                        }, {
3828                            "op":"eq",
3829                            "name": "span.attributes.browser.name.value",
3830                            "value": "Chrome"
3831                        }]
3832                    }
3833                }
3834            ]
3835        }))
3836        .unwrap();
3837
3838        eap::normalize_attribute_names(&mut span.attributes);
3839        normalize_performance_score(&mut span, Some(&performance_score));
3840
3841        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(span)), {}, @r###"
3842        {
3843          "start_timestamp": 1619420400.0,
3844          "end_timestamp": 1619420405.0,
3845          "attributes": {
3846            "browser.name": {
3847              "type": "string",
3848              "value": "Chrome",
3849            },
3850            "browser.version": {
3851              "type": "string",
3852              "value": "120.1.1",
3853            },
3854            "browser.web_vital.cls.value": {
3855              "type": "double",
3856              "value": 0.11,
3857            },
3858            "browser.web_vital.fcp.value": {
3859              "type": "double",
3860              "value": 1237.0,
3861            },
3862            "browser.web_vital.lcp.value": {
3863              "type": "double",
3864              "value": 6596,
3865            },
3866            "fid": {
3867              "type": "double",
3868              "value": 213,
3869            },
3870            "lcp": {
3871              "type": "double",
3872              "value": 6596,
3873            },
3874            "score.cls": {
3875              "type": "double",
3876              "value": 0.21864170607444863,
3877            },
3878            "score.fcp": {
3879              "type": "double",
3880              "value": 0.10750855443790831,
3881            },
3882            "score.fid": {
3883              "type": "double",
3884              "value": 0.19657361348282545,
3885            },
3886            "score.lcp": {
3887              "type": "double",
3888              "value": 0.009238896571386584,
3889            },
3890            "score.ratio.cls": {
3891              "type": "double",
3892              "value": 0.8745668242977945,
3893            },
3894            "score.ratio.fcp": {
3895              "type": "double",
3896              "value": 0.7167236962527221,
3897            },
3898            "score.ratio.fid": {
3899              "type": "double",
3900              "value": 0.6552453782760849,
3901            },
3902            "score.ratio.lcp": {
3903              "type": "double",
3904              "value": 0.03079632190462195,
3905            },
3906            "score.total": {
3907              "type": "double",
3908              "value": 0.531962770566569,
3909            },
3910            "score.weight.cls": {
3911              "type": "double",
3912              "value": 0.25,
3913            },
3914            "score.weight.fcp": {
3915              "type": "double",
3916              "value": 0.15,
3917            },
3918            "score.weight.fid": {
3919              "type": "double",
3920              "value": 0.3,
3921            },
3922            "score.weight.lcp": {
3923              "type": "double",
3924              "value": 0.3,
3925            },
3926            "score.weight.ttfb": {
3927              "type": "double",
3928              "value": 0.0,
3929            },
3930          },
3931        }
3932        "###);
3933    }
3934
3935    // Test performance score is calculated correctly when the sum of weights is under 1.
3936    // The expected result should normalize the weights to a sum of 1 and scale the weight measurements accordingly.
3937    #[test]
3938    fn test_computed_performance_score_with_under_normalized_weights() {
3939        let json = r#"
3940        {
3941            "type": "transaction",
3942            "timestamp": "2021-04-26T08:00:05+0100",
3943            "start_timestamp": "2021-04-26T08:00:00+0100",
3944            "measurements": {
3945                "fid": {"value": 213, "unit": "millisecond"},
3946                "fcp": {"value": 1237, "unit": "millisecond"},
3947                "lcp": {"value": 6596, "unit": "millisecond"},
3948                "cls": {"value": 0.11}
3949            },
3950            "contexts": {
3951                "browser": {
3952                    "name": "Chrome",
3953                    "version": "120.1.1",
3954                    "type": "browser"
3955                }
3956            }
3957        }
3958        "#;
3959
3960        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3961
3962        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3963            "profiles": [
3964                {
3965                    "name": "Desktop",
3966                    "scoreComponents": [
3967                        {
3968                            "measurement": "fcp",
3969                            "weight": 0.03,
3970                            "p10": 900,
3971                            "p50": 1600
3972                        },
3973                        {
3974                            "measurement": "lcp",
3975                            "weight": 0.06,
3976                            "p10": 1200,
3977                            "p50": 2400
3978                        },
3979                        {
3980                            "measurement": "fid",
3981                            "weight": 0.06,
3982                            "p10": 100,
3983                            "p50": 300
3984                        },
3985                        {
3986                            "measurement": "cls",
3987                            "weight": 0.05,
3988                            "p10": 0.1,
3989                            "p50": 0.25
3990                        },
3991                        {
3992                            "measurement": "ttfb",
3993                            "weight": 0.0,
3994                            "p10": 0.2,
3995                            "p50": 0.4
3996                        },
3997                    ],
3998                    "condition": {
3999                        "op":"eq",
4000                        "name": "event.contexts.browser.name",
4001                        "value": "Chrome"
4002                    }
4003                }
4004            ]
4005        }))
4006        .unwrap();
4007
4008        normalize_performance_score(&mut event, Some(&performance_score));
4009
4010        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4011        {
4012          "type": "transaction",
4013          "timestamp": 1619420405.0,
4014          "start_timestamp": 1619420400.0,
4015          "contexts": {
4016            "browser": {
4017              "name": "Chrome",
4018              "version": "120.1.1",
4019              "type": "browser",
4020            },
4021          },
4022          "measurements": {
4023            "cls": {
4024              "value": 0.11,
4025            },
4026            "fcp": {
4027              "value": 1237.0,
4028              "unit": "millisecond",
4029            },
4030            "fid": {
4031              "value": 213.0,
4032              "unit": "millisecond",
4033            },
4034            "lcp": {
4035              "value": 6596.0,
4036              "unit": "millisecond",
4037            },
4038            "score.cls": {
4039              "value": 0.21864170607444863,
4040              "unit": "ratio",
4041            },
4042            "score.fcp": {
4043              "value": 0.10750855443790831,
4044              "unit": "ratio",
4045            },
4046            "score.fid": {
4047              "value": 0.19657361348282545,
4048              "unit": "ratio",
4049            },
4050            "score.lcp": {
4051              "value": 0.009238896571386584,
4052              "unit": "ratio",
4053            },
4054            "score.ratio.cls": {
4055              "value": 0.8745668242977945,
4056              "unit": "ratio",
4057            },
4058            "score.ratio.fcp": {
4059              "value": 0.7167236962527221,
4060              "unit": "ratio",
4061            },
4062            "score.ratio.fid": {
4063              "value": 0.6552453782760849,
4064              "unit": "ratio",
4065            },
4066            "score.ratio.lcp": {
4067              "value": 0.03079632190462195,
4068              "unit": "ratio",
4069            },
4070            "score.total": {
4071              "value": 0.531962770566569,
4072              "unit": "ratio",
4073            },
4074            "score.weight.cls": {
4075              "value": 0.25,
4076              "unit": "ratio",
4077            },
4078            "score.weight.fcp": {
4079              "value": 0.15,
4080              "unit": "ratio",
4081            },
4082            "score.weight.fid": {
4083              "value": 0.3,
4084              "unit": "ratio",
4085            },
4086            "score.weight.lcp": {
4087              "value": 0.3,
4088              "unit": "ratio",
4089            },
4090            "score.weight.ttfb": {
4091              "value": 0.0,
4092              "unit": "ratio",
4093            },
4094          },
4095        }
4096        "###);
4097    }
4098
4099    // Test performance score is calculated correctly when the sum of weights is over 1.
4100    // The expected result should normalize the weights to a sum of 1 and scale the weight measurements accordingly.
4101    #[test]
4102    fn test_computed_performance_score_with_over_normalized_weights() {
4103        let json = r#"
4104        {
4105            "type": "transaction",
4106            "timestamp": "2021-04-26T08:00:05+0100",
4107            "start_timestamp": "2021-04-26T08:00:00+0100",
4108            "measurements": {
4109                "fid": {"value": 213, "unit": "millisecond"},
4110                "fcp": {"value": 1237, "unit": "millisecond"},
4111                "lcp": {"value": 6596, "unit": "millisecond"},
4112                "cls": {"value": 0.11}
4113            },
4114            "contexts": {
4115                "browser": {
4116                    "name": "Chrome",
4117                    "version": "120.1.1",
4118                    "type": "browser"
4119                }
4120            }
4121        }
4122        "#;
4123
4124        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4125
4126        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4127            "profiles": [
4128                {
4129                    "name": "Desktop",
4130                    "scoreComponents": [
4131                        {
4132                            "measurement": "fcp",
4133                            "weight": 0.30,
4134                            "p10": 900,
4135                            "p50": 1600
4136                        },
4137                        {
4138                            "measurement": "lcp",
4139                            "weight": 0.60,
4140                            "p10": 1200,
4141                            "p50": 2400
4142                        },
4143                        {
4144                            "measurement": "fid",
4145                            "weight": 0.60,
4146                            "p10": 100,
4147                            "p50": 300
4148                        },
4149                        {
4150                            "measurement": "cls",
4151                            "weight": 0.50,
4152                            "p10": 0.1,
4153                            "p50": 0.25
4154                        },
4155                        {
4156                            "measurement": "ttfb",
4157                            "weight": 0.0,
4158                            "p10": 0.2,
4159                            "p50": 0.4
4160                        },
4161                    ],
4162                    "condition": {
4163                        "op":"eq",
4164                        "name": "event.contexts.browser.name",
4165                        "value": "Chrome"
4166                    }
4167                }
4168            ]
4169        }))
4170        .unwrap();
4171
4172        normalize_performance_score(&mut event, Some(&performance_score));
4173
4174        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4175        {
4176          "type": "transaction",
4177          "timestamp": 1619420405.0,
4178          "start_timestamp": 1619420400.0,
4179          "contexts": {
4180            "browser": {
4181              "name": "Chrome",
4182              "version": "120.1.1",
4183              "type": "browser",
4184            },
4185          },
4186          "measurements": {
4187            "cls": {
4188              "value": 0.11,
4189            },
4190            "fcp": {
4191              "value": 1237.0,
4192              "unit": "millisecond",
4193            },
4194            "fid": {
4195              "value": 213.0,
4196              "unit": "millisecond",
4197            },
4198            "lcp": {
4199              "value": 6596.0,
4200              "unit": "millisecond",
4201            },
4202            "score.cls": {
4203              "value": 0.21864170607444863,
4204              "unit": "ratio",
4205            },
4206            "score.fcp": {
4207              "value": 0.10750855443790831,
4208              "unit": "ratio",
4209            },
4210            "score.fid": {
4211              "value": 0.19657361348282545,
4212              "unit": "ratio",
4213            },
4214            "score.lcp": {
4215              "value": 0.009238896571386584,
4216              "unit": "ratio",
4217            },
4218            "score.ratio.cls": {
4219              "value": 0.8745668242977945,
4220              "unit": "ratio",
4221            },
4222            "score.ratio.fcp": {
4223              "value": 0.7167236962527221,
4224              "unit": "ratio",
4225            },
4226            "score.ratio.fid": {
4227              "value": 0.6552453782760849,
4228              "unit": "ratio",
4229            },
4230            "score.ratio.lcp": {
4231              "value": 0.03079632190462195,
4232              "unit": "ratio",
4233            },
4234            "score.total": {
4235              "value": 0.531962770566569,
4236              "unit": "ratio",
4237            },
4238            "score.weight.cls": {
4239              "value": 0.25,
4240              "unit": "ratio",
4241            },
4242            "score.weight.fcp": {
4243              "value": 0.15,
4244              "unit": "ratio",
4245            },
4246            "score.weight.fid": {
4247              "value": 0.3,
4248              "unit": "ratio",
4249            },
4250            "score.weight.lcp": {
4251              "value": 0.3,
4252              "unit": "ratio",
4253            },
4254            "score.weight.ttfb": {
4255              "value": 0.0,
4256              "unit": "ratio",
4257            },
4258          },
4259        }
4260        "###);
4261    }
4262
4263    #[test]
4264    fn test_computed_performance_score_missing_measurement() {
4265        let json = r#"
4266        {
4267            "type": "transaction",
4268            "timestamp": "2021-04-26T08:00:05+0100",
4269            "start_timestamp": "2021-04-26T08:00:00+0100",
4270            "measurements": {
4271                "a": {"value": 213, "unit": "millisecond"}
4272            },
4273            "contexts": {
4274                "browser": {
4275                    "name": "Chrome",
4276                    "version": "120.1.1",
4277                    "type": "browser"
4278                }
4279            }
4280        }
4281        "#;
4282
4283        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4284
4285        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4286            "profiles": [
4287                {
4288                    "name": "Desktop",
4289                    "scoreComponents": [
4290                        {
4291                            "measurement": "a",
4292                            "weight": 0.15,
4293                            "p10": 900,
4294                            "p50": 1600
4295                        },
4296                        {
4297                            "measurement": "b",
4298                            "weight": 0.30,
4299                            "p10": 1200,
4300                            "p50": 2400
4301                        },
4302                    ],
4303                    "condition": {
4304                        "op":"eq",
4305                        "name": "event.contexts.browser.name",
4306                        "value": "Chrome"
4307                    }
4308                }
4309            ]
4310        }))
4311        .unwrap();
4312
4313        normalize_performance_score(&mut event, Some(&performance_score));
4314
4315        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4316        {
4317          "type": "transaction",
4318          "timestamp": 1619420405.0,
4319          "start_timestamp": 1619420400.0,
4320          "contexts": {
4321            "browser": {
4322              "name": "Chrome",
4323              "version": "120.1.1",
4324              "type": "browser",
4325            },
4326          },
4327          "measurements": {
4328            "a": {
4329              "value": 213.0,
4330              "unit": "millisecond",
4331            },
4332          },
4333        }
4334        "###);
4335    }
4336
4337    #[test]
4338    fn test_computed_performance_score_optional_measurement() {
4339        let json = r#"
4340        {
4341            "type": "transaction",
4342            "timestamp": "2021-04-26T08:00:05+0100",
4343            "start_timestamp": "2021-04-26T08:00:00+0100",
4344            "measurements": {
4345                "a": {"value": 213, "unit": "millisecond"},
4346                "b": {"value": 213, "unit": "millisecond"}
4347            },
4348            "contexts": {
4349                "browser": {
4350                    "name": "Chrome",
4351                    "version": "120.1.1",
4352                    "type": "browser"
4353                }
4354            }
4355        }
4356        "#;
4357
4358        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4359
4360        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4361            "profiles": [
4362                {
4363                    "name": "Desktop",
4364                    "scoreComponents": [
4365                        {
4366                            "measurement": "a",
4367                            "weight": 0.15,
4368                            "p10": 900,
4369                            "p50": 1600,
4370                        },
4371                        {
4372                            "measurement": "b",
4373                            "weight": 0.30,
4374                            "p10": 1200,
4375                            "p50": 2400,
4376                            "optional": true
4377                        },
4378                        {
4379                            "measurement": "c",
4380                            "weight": 0.55,
4381                            "p10": 1200,
4382                            "p50": 2400,
4383                            "optional": true
4384                        },
4385                    ],
4386                    "condition": {
4387                        "op":"eq",
4388                        "name": "event.contexts.browser.name",
4389                        "value": "Chrome"
4390                    }
4391                }
4392            ]
4393        }))
4394        .unwrap();
4395
4396        normalize_performance_score(&mut event, Some(&performance_score));
4397
4398        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4399        {
4400          "type": "transaction",
4401          "timestamp": 1619420405.0,
4402          "start_timestamp": 1619420400.0,
4403          "contexts": {
4404            "browser": {
4405              "name": "Chrome",
4406              "version": "120.1.1",
4407              "type": "browser",
4408            },
4409          },
4410          "measurements": {
4411            "a": {
4412              "value": 213.0,
4413              "unit": "millisecond",
4414            },
4415            "b": {
4416              "value": 213.0,
4417              "unit": "millisecond",
4418            },
4419            "score.a": {
4420              "value": 0.33333215313291975,
4421              "unit": "ratio",
4422            },
4423            "score.b": {
4424              "value": 0.66666415149198,
4425              "unit": "ratio",
4426            },
4427            "score.ratio.a": {
4428              "value": 0.9999964593987591,
4429              "unit": "ratio",
4430            },
4431            "score.ratio.b": {
4432              "value": 0.9999962272379699,
4433              "unit": "ratio",
4434            },
4435            "score.total": {
4436              "value": 0.9999963046248997,
4437              "unit": "ratio",
4438            },
4439            "score.weight.a": {
4440              "value": 0.33333333333333337,
4441              "unit": "ratio",
4442            },
4443            "score.weight.b": {
4444              "value": 0.6666666666666667,
4445              "unit": "ratio",
4446            },
4447            "score.weight.c": {
4448              "value": 0.0,
4449              "unit": "ratio",
4450            },
4451          },
4452        }
4453        "###);
4454    }
4455
4456    #[test]
4457    fn test_computed_performance_score_weight_0() {
4458        let json = r#"
4459        {
4460            "type": "transaction",
4461            "timestamp": "2021-04-26T08:00:05+0100",
4462            "start_timestamp": "2021-04-26T08:00:00+0100",
4463            "measurements": {
4464                "cls": {"value": 0.11}
4465            }
4466        }
4467        "#;
4468
4469        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4470
4471        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4472            "profiles": [
4473                {
4474                    "name": "Desktop",
4475                    "scoreComponents": [
4476                        {
4477                            "measurement": "cls",
4478                            "weight": 0,
4479                            "p10": 0.1,
4480                            "p50": 0.25
4481                        },
4482                    ],
4483                    "condition": {
4484                        "op":"and",
4485                        "inner": []
4486                    }
4487                }
4488            ]
4489        }))
4490        .unwrap();
4491
4492        normalize_performance_score(&mut event, Some(&performance_score));
4493
4494        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4495        {
4496          "type": "transaction",
4497          "timestamp": 1619420405.0,
4498          "start_timestamp": 1619420400.0,
4499          "measurements": {
4500            "cls": {
4501              "value": 0.11,
4502            },
4503          },
4504        }
4505        "###);
4506    }
4507
4508    #[test]
4509    fn test_computed_performance_score_negative_value() {
4510        let json = r#"
4511        {
4512            "type": "transaction",
4513            "timestamp": "2021-04-26T08:00:05+0100",
4514            "start_timestamp": "2021-04-26T08:00:00+0100",
4515            "measurements": {
4516                "ttfb": {"value": -100, "unit": "millisecond"}
4517            }
4518        }
4519        "#;
4520
4521        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4522
4523        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4524            "profiles": [
4525                {
4526                    "name": "Desktop",
4527                    "scoreComponents": [
4528                        {
4529                            "measurement": "ttfb",
4530                            "weight": 1.0,
4531                            "p10": 100.0,
4532                            "p50": 250.0
4533                        },
4534                    ],
4535                    "condition": {
4536                        "op":"and",
4537                        "inner": []
4538                    }
4539                }
4540            ]
4541        }))
4542        .unwrap();
4543
4544        normalize_performance_score(&mut event, Some(&performance_score));
4545
4546        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4547        {
4548          "type": "transaction",
4549          "timestamp": 1619420405.0,
4550          "start_timestamp": 1619420400.0,
4551          "measurements": {
4552            "score.ratio.ttfb": {
4553              "value": 1.0,
4554              "unit": "ratio",
4555            },
4556            "score.total": {
4557              "value": 1.0,
4558              "unit": "ratio",
4559            },
4560            "score.ttfb": {
4561              "value": 1.0,
4562              "unit": "ratio",
4563            },
4564            "score.weight.ttfb": {
4565              "value": 1.0,
4566              "unit": "ratio",
4567            },
4568            "ttfb": {
4569              "value": -100.0,
4570              "unit": "millisecond",
4571            },
4572          },
4573        }
4574        "###);
4575    }
4576
4577    #[test]
4578    fn test_filter_negative_web_vital_measurements() {
4579        let json = r#"
4580        {
4581            "type": "transaction",
4582            "timestamp": "2021-04-26T08:00:05+0100",
4583            "start_timestamp": "2021-04-26T08:00:00+0100",
4584            "measurements": {
4585                "ttfb": {"value": -100, "unit": "millisecond"}
4586            }
4587        }
4588        "#;
4589        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4590
4591        // Allow ttfb as a builtinMeasurement with allow_negative defaulted to false.
4592        let project_measurement_config: MeasurementsConfig = serde_json::from_value(json!({
4593            "builtinMeasurements": [
4594                {"name": "ttfb", "unit": "millisecond"},
4595            ],
4596        }))
4597        .unwrap();
4598
4599        let dynamic_measurement_config =
4600            CombinedMeasurementsConfig::new(Some(&project_measurement_config), None);
4601
4602        normalize_event_measurements(&mut event, Some(dynamic_measurement_config), None);
4603
4604        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4605        {
4606          "type": "transaction",
4607          "timestamp": 1619420405.0,
4608          "start_timestamp": 1619420400.0,
4609          "measurements": {},
4610          "_meta": {
4611            "measurements": {
4612              "": Meta(Some(MetaInner(
4613                err: [
4614                  [
4615                    "invalid_data",
4616                    {
4617                      "reason": "Negative value for measurement ttfb not allowed: -100",
4618                    },
4619                  ],
4620                ],
4621                val: Some({
4622                  "ttfb": {
4623                    "unit": "millisecond",
4624                    "value": -100.0,
4625                  },
4626                }),
4627              ))),
4628            },
4629          },
4630        }
4631        "###);
4632    }
4633
4634    #[test]
4635    fn test_computed_performance_score_multiple_profiles() {
4636        let json = r#"
4637        {
4638            "type": "transaction",
4639            "timestamp": "2021-04-26T08:00:05+0100",
4640            "start_timestamp": "2021-04-26T08:00:00+0100",
4641            "measurements": {
4642                "cls": {"value": 0.11},
4643                "inp": {"value": 120.0}
4644            }
4645        }
4646        "#;
4647
4648        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4649
4650        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4651            "profiles": [
4652                {
4653                    "name": "Desktop",
4654                    "scoreComponents": [
4655                        {
4656                            "measurement": "cls",
4657                            "weight": 0,
4658                            "p10": 0.1,
4659                            "p50": 0.25
4660                        },
4661                    ],
4662                    "condition": {
4663                        "op":"and",
4664                        "inner": []
4665                    }
4666                },
4667                {
4668                    "name": "Desktop",
4669                    "scoreComponents": [
4670                        {
4671                            "measurement": "inp",
4672                            "weight": 1.0,
4673                            "p10": 0.1,
4674                            "p50": 0.25
4675                        },
4676                    ],
4677                    "condition": {
4678                        "op":"and",
4679                        "inner": []
4680                    }
4681                }
4682            ]
4683        }))
4684        .unwrap();
4685
4686        normalize_performance_score(&mut event, Some(&performance_score));
4687
4688        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4689        {
4690          "type": "transaction",
4691          "timestamp": 1619420405.0,
4692          "start_timestamp": 1619420400.0,
4693          "measurements": {
4694            "cls": {
4695              "value": 0.11,
4696            },
4697            "inp": {
4698              "value": 120.0,
4699            },
4700            "score.inp": {
4701              "value": 0.0,
4702              "unit": "ratio",
4703            },
4704            "score.ratio.inp": {
4705              "value": 0.0,
4706              "unit": "ratio",
4707            },
4708            "score.total": {
4709              "value": 0.0,
4710              "unit": "ratio",
4711            },
4712            "score.weight.inp": {
4713              "value": 1.0,
4714              "unit": "ratio",
4715            },
4716          },
4717        }
4718        "###);
4719    }
4720
4721    #[test]
4722    fn test_compute_performance_score_for_mobile_ios_profile() {
4723        let mut event = Annotated::<Event>::from_json(IOS_MOBILE_EVENT)
4724            .unwrap()
4725            .0
4726            .unwrap();
4727
4728        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4729            "profiles": [
4730                {
4731                    "name": "Mobile",
4732                    "scoreComponents": [
4733                        {
4734                            "measurement": "time_to_initial_display",
4735                            "weight": 0.25,
4736                            "p10": 1800.0,
4737                            "p50": 3000.0,
4738                            "optional": true
4739                        },
4740                        {
4741                            "measurement": "time_to_full_display",
4742                            "weight": 0.25,
4743                            "p10": 2500.0,
4744                            "p50": 4000.0,
4745                            "optional": true
4746                        },
4747                        {
4748                            "measurement": "app_start_warm",
4749                            "weight": 0.25,
4750                            "p10": 200.0,
4751                            "p50": 500.0,
4752                            "optional": true
4753                        },
4754                        {
4755                            "measurement": "app_start_cold",
4756                            "weight": 0.25,
4757                            "p10": 200.0,
4758                            "p50": 500.0,
4759                            "optional": true
4760                        }
4761                    ],
4762                    "condition": {
4763                        "op": "and",
4764                        "inner": [
4765                            {
4766                                "op": "or",
4767                                "inner": [
4768                                    {
4769                                        "op": "eq",
4770                                        "name": "event.sdk.name",
4771                                        "value": "sentry.cocoa"
4772                                    },
4773                                    {
4774                                        "op": "eq",
4775                                        "name": "event.sdk.name",
4776                                        "value": "sentry.java.android"
4777                                    }
4778                                ]
4779                            },
4780                            {
4781                                "op": "eq",
4782                                "name": "event.contexts.trace.op",
4783                                "value": "ui.load"
4784                            }
4785                        ]
4786                    }
4787                }
4788            ]
4789        }))
4790        .unwrap();
4791
4792        normalize_performance_score(&mut event, Some(&performance_score));
4793
4794        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {});
4795    }
4796
4797    #[test]
4798    fn test_compute_performance_score_for_mobile_android_profile() {
4799        let mut event = Annotated::<Event>::from_json(ANDROID_MOBILE_EVENT)
4800            .unwrap()
4801            .0
4802            .unwrap();
4803
4804        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4805            "profiles": [
4806                {
4807                    "name": "Mobile",
4808                    "scoreComponents": [
4809                        {
4810                            "measurement": "time_to_initial_display",
4811                            "weight": 0.25,
4812                            "p10": 1800.0,
4813                            "p50": 3000.0,
4814                            "optional": true
4815                        },
4816                        {
4817                            "measurement": "time_to_full_display",
4818                            "weight": 0.25,
4819                            "p10": 2500.0,
4820                            "p50": 4000.0,
4821                            "optional": true
4822                        },
4823                        {
4824                            "measurement": "app_start_warm",
4825                            "weight": 0.25,
4826                            "p10": 200.0,
4827                            "p50": 500.0,
4828                            "optional": true
4829                        },
4830                        {
4831                            "measurement": "app_start_cold",
4832                            "weight": 0.25,
4833                            "p10": 200.0,
4834                            "p50": 500.0,
4835                            "optional": true
4836                        }
4837                    ],
4838                    "condition": {
4839                        "op": "and",
4840                        "inner": [
4841                            {
4842                                "op": "or",
4843                                "inner": [
4844                                    {
4845                                        "op": "eq",
4846                                        "name": "event.sdk.name",
4847                                        "value": "sentry.cocoa"
4848                                    },
4849                                    {
4850                                        "op": "eq",
4851                                        "name": "event.sdk.name",
4852                                        "value": "sentry.java.android"
4853                                    }
4854                                ]
4855                            },
4856                            {
4857                                "op": "eq",
4858                                "name": "event.contexts.trace.op",
4859                                "value": "ui.load"
4860                            }
4861                        ]
4862                    }
4863                }
4864            ]
4865        }))
4866        .unwrap();
4867
4868        normalize_performance_score(&mut event, Some(&performance_score));
4869
4870        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {});
4871    }
4872
4873    #[test]
4874    fn test_computes_performance_score_and_tags_with_profile_version() {
4875        let json = r#"
4876        {
4877            "type": "transaction",
4878            "timestamp": "2021-04-26T08:00:05+0100",
4879            "start_timestamp": "2021-04-26T08:00:00+0100",
4880            "measurements": {
4881                "inp": {"value": 120.0}
4882            }
4883        }
4884        "#;
4885
4886        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4887
4888        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4889            "profiles": [
4890                {
4891                    "name": "Desktop",
4892                    "scoreComponents": [
4893                        {
4894                            "measurement": "inp",
4895                            "weight": 1.0,
4896                            "p10": 0.1,
4897                            "p50": 0.25
4898                        },
4899                    ],
4900                    "condition": {
4901                        "op":"and",
4902                        "inner": []
4903                    },
4904                    "version": "beta"
4905                }
4906            ]
4907        }))
4908        .unwrap();
4909
4910        normalize(
4911            &mut event,
4912            &mut Meta::default(),
4913            &NormalizationConfig {
4914                performance_score: Some(&performance_score),
4915                ..Default::default()
4916            },
4917        );
4918
4919        insta::assert_ron_snapshot!(SerializableAnnotated(&event.contexts), {}, @r###"
4920        {
4921          "performance_score": {
4922            "score_profile_version": "beta",
4923            "type": "performancescore",
4924          },
4925        }
4926        "###);
4927        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
4928        {
4929          "inp": {
4930            "value": 120.0,
4931            "unit": "millisecond",
4932          },
4933          "score.inp": {
4934            "value": 0.0,
4935            "unit": "ratio",
4936          },
4937          "score.ratio.inp": {
4938            "value": 0.0,
4939            "unit": "ratio",
4940          },
4941          "score.total": {
4942            "value": 0.0,
4943            "unit": "ratio",
4944          },
4945          "score.weight.inp": {
4946            "value": 1.0,
4947            "unit": "ratio",
4948          },
4949        }
4950        "###);
4951    }
4952
4953    #[test]
4954    fn test_normalize_adds_trace_context() {
4955        let json = r#"
4956        {
4957            "type": "error",
4958            "exception": {
4959                "values": [{"type": "ValueError", "value": "Should not happen"}]
4960            }
4961        }
4962        "#;
4963
4964        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4965
4966        normalize(
4967            &mut event,
4968            &mut Meta::default(),
4969            &NormalizationConfig {
4970                force_trace_context: true,
4971                ..Default::default()
4972            },
4973        );
4974
4975        insta::assert_ron_snapshot!(SerializableAnnotated(&event.contexts), {
4976            ".event_id" => "[event-id]",
4977            ".trace.trace_id" => "[trace-id]",
4978            ".trace.span_id" => "[span-id]"
4979        }, @r#"
4980        {
4981          "trace": {
4982            "trace_id": "[trace-id]",
4983            "span_id": "[span-id]",
4984            "status": "unknown",
4985            "type": "trace",
4986          },
4987        }
4988        "#);
4989    }
4990
4991    #[test]
4992    fn test_computes_standalone_cls_performance_score() {
4993        let json = r#"
4994        {
4995            "type": "transaction",
4996            "timestamp": "2021-04-26T08:00:05+0100",
4997            "start_timestamp": "2021-04-26T08:00:00+0100",
4998            "measurements": {
4999                "cls": {"value": 0.5}
5000            }
5001        }
5002        "#;
5003
5004        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5005
5006        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5007            "profiles": [
5008            {
5009                "name": "Default",
5010                "scoreComponents": [
5011                    {
5012                        "measurement": "fcp",
5013                        "weight": 0.15,
5014                        "p10": 900.0,
5015                        "p50": 1600.0,
5016                        "optional": true,
5017                    },
5018                    {
5019                        "measurement": "lcp",
5020                        "weight": 0.30,
5021                        "p10": 1200.0,
5022                        "p50": 2400.0,
5023                        "optional": true,
5024                    },
5025                    {
5026                        "measurement": "cls",
5027                        "weight": 0.15,
5028                        "p10": 0.1,
5029                        "p50": 0.25,
5030                        "optional": true,
5031                    },
5032                    {
5033                        "measurement": "ttfb",
5034                        "weight": 0.10,
5035                        "p10": 200.0,
5036                        "p50": 400.0,
5037                        "optional": true,
5038                    },
5039                ],
5040                "condition": {
5041                    "op": "and",
5042                    "inner": [],
5043                },
5044            }
5045            ]
5046        }))
5047        .unwrap();
5048
5049        normalize(
5050            &mut event,
5051            &mut Meta::default(),
5052            &NormalizationConfig {
5053                performance_score: Some(&performance_score),
5054                ..Default::default()
5055            },
5056        );
5057
5058        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
5059        {
5060          "cls": {
5061            "value": 0.5,
5062            "unit": "none",
5063          },
5064          "score.cls": {
5065            "value": 0.16615877613713903,
5066            "unit": "ratio",
5067          },
5068          "score.ratio.cls": {
5069            "value": 0.16615877613713903,
5070            "unit": "ratio",
5071          },
5072          "score.total": {
5073            "value": 0.16615877613713903,
5074            "unit": "ratio",
5075          },
5076          "score.weight.cls": {
5077            "value": 1.0,
5078            "unit": "ratio",
5079          },
5080          "score.weight.fcp": {
5081            "value": 0.0,
5082            "unit": "ratio",
5083          },
5084          "score.weight.lcp": {
5085            "value": 0.0,
5086            "unit": "ratio",
5087          },
5088          "score.weight.ttfb": {
5089            "value": 0.0,
5090            "unit": "ratio",
5091          },
5092        }
5093        "###);
5094    }
5095
5096    #[test]
5097    fn test_computes_standalone_lcp_performance_score() {
5098        let json = r#"
5099        {
5100            "type": "transaction",
5101            "timestamp": "2021-04-26T08:00:05+0100",
5102            "start_timestamp": "2021-04-26T08:00:00+0100",
5103            "measurements": {
5104                "lcp": {"value": 1200.0}
5105            }
5106        }
5107        "#;
5108
5109        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5110
5111        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5112            "profiles": [
5113            {
5114                "name": "Default",
5115                "scoreComponents": [
5116                    {
5117                        "measurement": "fcp",
5118                        "weight": 0.15,
5119                        "p10": 900.0,
5120                        "p50": 1600.0,
5121                        "optional": true,
5122                    },
5123                    {
5124                        "measurement": "lcp",
5125                        "weight": 0.30,
5126                        "p10": 1200.0,
5127                        "p50": 2400.0,
5128                        "optional": true,
5129                    },
5130                    {
5131                        "measurement": "cls",
5132                        "weight": 0.15,
5133                        "p10": 0.1,
5134                        "p50": 0.25,
5135                        "optional": true,
5136                    },
5137                    {
5138                        "measurement": "ttfb",
5139                        "weight": 0.10,
5140                        "p10": 200.0,
5141                        "p50": 400.0,
5142                        "optional": true,
5143                    },
5144                ],
5145                "condition": {
5146                    "op": "and",
5147                    "inner": [],
5148                },
5149            }
5150            ]
5151        }))
5152        .unwrap();
5153
5154        normalize(
5155            &mut event,
5156            &mut Meta::default(),
5157            &NormalizationConfig {
5158                performance_score: Some(&performance_score),
5159                ..Default::default()
5160            },
5161        );
5162
5163        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
5164        {
5165          "lcp": {
5166            "value": 1200.0,
5167            "unit": "millisecond",
5168          },
5169          "score.lcp": {
5170            "value": 0.8999999314038525,
5171            "unit": "ratio",
5172          },
5173          "score.ratio.lcp": {
5174            "value": 0.8999999314038525,
5175            "unit": "ratio",
5176          },
5177          "score.total": {
5178            "value": 0.8999999314038525,
5179            "unit": "ratio",
5180          },
5181          "score.weight.cls": {
5182            "value": 0.0,
5183            "unit": "ratio",
5184          },
5185          "score.weight.fcp": {
5186            "value": 0.0,
5187            "unit": "ratio",
5188          },
5189          "score.weight.lcp": {
5190            "value": 1.0,
5191            "unit": "ratio",
5192          },
5193          "score.weight.ttfb": {
5194            "value": 0.0,
5195            "unit": "ratio",
5196          },
5197        }
5198        "###);
5199    }
5200
5201    #[test]
5202    fn test_computed_performance_score_uses_first_matching_profile() {
5203        let json = r#"
5204        {
5205            "type": "transaction",
5206            "timestamp": "2021-04-26T08:00:05+0100",
5207            "start_timestamp": "2021-04-26T08:00:00+0100",
5208            "measurements": {
5209                "a": {"value": 213, "unit": "millisecond"},
5210                "b": {"value": 213, "unit": "millisecond"}
5211            },
5212            "contexts": {
5213                "browser": {
5214                    "name": "Chrome",
5215                    "version": "120.1.1",
5216                    "type": "browser"
5217                }
5218            }
5219        }
5220        "#;
5221
5222        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5223
5224        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5225            "profiles": [
5226                {
5227                    "name": "Mobile",
5228                    "scoreComponents": [
5229                        {
5230                            "measurement": "a",
5231                            "weight": 0.15,
5232                            "p10": 100,
5233                            "p50": 200,
5234                        },
5235                        {
5236                            "measurement": "b",
5237                            "weight": 0.30,
5238                            "p10": 100,
5239                            "p50": 200,
5240                            "optional": true
5241                        },
5242                        {
5243                            "measurement": "c",
5244                            "weight": 0.55,
5245                            "p10": 100,
5246                            "p50": 200,
5247                            "optional": true
5248                        },
5249                    ],
5250                    "condition": {
5251                        "op":"eq",
5252                        "name": "event.contexts.browser.name",
5253                        "value": "Chrome Mobile"
5254                    }
5255                },
5256                {
5257                    "name": "Desktop",
5258                    "scoreComponents": [
5259                        {
5260                            "measurement": "a",
5261                            "weight": 0.15,
5262                            "p10": 900,
5263                            "p50": 1600,
5264                        },
5265                        {
5266                            "measurement": "b",
5267                            "weight": 0.30,
5268                            "p10": 1200,
5269                            "p50": 2400,
5270                            "optional": true
5271                        },
5272                        {
5273                            "measurement": "c",
5274                            "weight": 0.55,
5275                            "p10": 1200,
5276                            "p50": 2400,
5277                            "optional": true
5278                        },
5279                    ],
5280                    "condition": {
5281                        "op":"eq",
5282                        "name": "event.contexts.browser.name",
5283                        "value": "Chrome"
5284                    }
5285                },
5286                {
5287                    "name": "Default",
5288                    "scoreComponents": [
5289                        {
5290                            "measurement": "a",
5291                            "weight": 0.15,
5292                            "p10": 100,
5293                            "p50": 200,
5294                        },
5295                        {
5296                            "measurement": "b",
5297                            "weight": 0.30,
5298                            "p10": 100,
5299                            "p50": 200,
5300                            "optional": true
5301                        },
5302                        {
5303                            "measurement": "c",
5304                            "weight": 0.55,
5305                            "p10": 100,
5306                            "p50": 200,
5307                            "optional": true
5308                        },
5309                    ],
5310                    "condition": {
5311                        "op": "and",
5312                        "inner": [],
5313                    }
5314                }
5315            ]
5316        }))
5317        .unwrap();
5318
5319        normalize_performance_score(&mut event, Some(&performance_score));
5320
5321        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5322        {
5323          "type": "transaction",
5324          "timestamp": 1619420405.0,
5325          "start_timestamp": 1619420400.0,
5326          "contexts": {
5327            "browser": {
5328              "name": "Chrome",
5329              "version": "120.1.1",
5330              "type": "browser",
5331            },
5332          },
5333          "measurements": {
5334            "a": {
5335              "value": 213.0,
5336              "unit": "millisecond",
5337            },
5338            "b": {
5339              "value": 213.0,
5340              "unit": "millisecond",
5341            },
5342            "score.a": {
5343              "value": 0.33333215313291975,
5344              "unit": "ratio",
5345            },
5346            "score.b": {
5347              "value": 0.66666415149198,
5348              "unit": "ratio",
5349            },
5350            "score.ratio.a": {
5351              "value": 0.9999964593987591,
5352              "unit": "ratio",
5353            },
5354            "score.ratio.b": {
5355              "value": 0.9999962272379699,
5356              "unit": "ratio",
5357            },
5358            "score.total": {
5359              "value": 0.9999963046248997,
5360              "unit": "ratio",
5361            },
5362            "score.weight.a": {
5363              "value": 0.33333333333333337,
5364              "unit": "ratio",
5365            },
5366            "score.weight.b": {
5367              "value": 0.6666666666666667,
5368              "unit": "ratio",
5369            },
5370            "score.weight.c": {
5371              "value": 0.0,
5372              "unit": "ratio",
5373            },
5374          },
5375        }
5376        "###);
5377    }
5378
5379    #[test]
5380    fn test_computed_performance_score_falls_back_to_default_profile() {
5381        let json = r#"
5382        {
5383            "type": "transaction",
5384            "timestamp": "2021-04-26T08:00:05+0100",
5385            "start_timestamp": "2021-04-26T08:00:00+0100",
5386            "measurements": {
5387                "a": {"value": 213, "unit": "millisecond"},
5388                "b": {"value": 213, "unit": "millisecond"}
5389            },
5390            "contexts": {}
5391        }
5392        "#;
5393
5394        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5395
5396        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
5397            "profiles": [
5398                {
5399                    "name": "Mobile",
5400                    "scoreComponents": [
5401                        {
5402                            "measurement": "a",
5403                            "weight": 0.15,
5404                            "p10": 900,
5405                            "p50": 1600,
5406                            "optional": true
5407                        },
5408                        {
5409                            "measurement": "b",
5410                            "weight": 0.30,
5411                            "p10": 1200,
5412                            "p50": 2400,
5413                            "optional": true
5414                        },
5415                        {
5416                            "measurement": "c",
5417                            "weight": 0.55,
5418                            "p10": 1200,
5419                            "p50": 2400,
5420                            "optional": true
5421                        },
5422                    ],
5423                    "condition": {
5424                        "op":"eq",
5425                        "name": "event.contexts.browser.name",
5426                        "value": "Chrome Mobile"
5427                    }
5428                },
5429                {
5430                    "name": "Desktop",
5431                    "scoreComponents": [
5432                        {
5433                            "measurement": "a",
5434                            "weight": 0.15,
5435                            "p10": 900,
5436                            "p50": 1600,
5437                            "optional": true
5438                        },
5439                        {
5440                            "measurement": "b",
5441                            "weight": 0.30,
5442                            "p10": 1200,
5443                            "p50": 2400,
5444                            "optional": true
5445                        },
5446                        {
5447                            "measurement": "c",
5448                            "weight": 0.55,
5449                            "p10": 1200,
5450                            "p50": 2400,
5451                            "optional": true
5452                        },
5453                    ],
5454                    "condition": {
5455                        "op":"eq",
5456                        "name": "event.contexts.browser.name",
5457                        "value": "Chrome"
5458                    }
5459                },
5460                {
5461                    "name": "Default",
5462                    "scoreComponents": [
5463                        {
5464                            "measurement": "a",
5465                            "weight": 0.15,
5466                            "p10": 100,
5467                            "p50": 200,
5468                            "optional": true
5469                        },
5470                        {
5471                            "measurement": "b",
5472                            "weight": 0.30,
5473                            "p10": 100,
5474                            "p50": 200,
5475                            "optional": true
5476                        },
5477                        {
5478                            "measurement": "c",
5479                            "weight": 0.55,
5480                            "p10": 100,
5481                            "p50": 200,
5482                            "optional": true
5483                        },
5484                    ],
5485                    "condition": {
5486                        "op": "and",
5487                        "inner": [],
5488                    }
5489                }
5490            ]
5491        }))
5492        .unwrap();
5493
5494        normalize_performance_score(&mut event, Some(&performance_score));
5495
5496        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5497        {
5498          "type": "transaction",
5499          "timestamp": 1619420405.0,
5500          "start_timestamp": 1619420400.0,
5501          "contexts": {},
5502          "measurements": {
5503            "a": {
5504              "value": 213.0,
5505              "unit": "millisecond",
5506            },
5507            "b": {
5508              "value": 213.0,
5509              "unit": "millisecond",
5510            },
5511            "score.a": {
5512              "value": 0.15121816827413334,
5513              "unit": "ratio",
5514            },
5515            "score.b": {
5516              "value": 0.3024363365482667,
5517              "unit": "ratio",
5518            },
5519            "score.ratio.a": {
5520              "value": 0.45365450482239994,
5521              "unit": "ratio",
5522            },
5523            "score.ratio.b": {
5524              "value": 0.45365450482239994,
5525              "unit": "ratio",
5526            },
5527            "score.total": {
5528              "value": 0.4536545048224,
5529              "unit": "ratio",
5530            },
5531            "score.weight.a": {
5532              "value": 0.33333333333333337,
5533              "unit": "ratio",
5534            },
5535            "score.weight.b": {
5536              "value": 0.6666666666666667,
5537              "unit": "ratio",
5538            },
5539            "score.weight.c": {
5540              "value": 0.0,
5541              "unit": "ratio",
5542            },
5543          },
5544        }
5545        "###);
5546    }
5547
5548    #[test]
5549    fn test_normalization_removes_reprocessing_context() {
5550        let json = r#"{
5551            "contexts": {
5552                "reprocessing": {}
5553            }
5554        }"#;
5555        let mut event = Annotated::<Event>::from_json(json).unwrap();
5556        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
5557        normalize_event(&mut event, &NormalizationConfig::default());
5558        assert!(!get_value!(event.contexts!).contains_key("reprocessing"));
5559    }
5560
5561    #[test]
5562    fn test_renormalization_does_not_remove_reprocessing_context() {
5563        let json = r#"{
5564            "contexts": {
5565                "reprocessing": {}
5566            }
5567        }"#;
5568        let mut event = Annotated::<Event>::from_json(json).unwrap();
5569        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
5570        normalize_event(
5571            &mut event,
5572            &NormalizationConfig {
5573                is_renormalize: true,
5574                ..Default::default()
5575            },
5576        );
5577        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
5578    }
5579
5580    #[test]
5581    fn test_normalize_user() {
5582        let json = r#"{
5583            "user": {
5584                "id": "123456",
5585                "username": "john",
5586                "other": "value"
5587            }
5588        }"#;
5589        let mut event = Annotated::<Event>::from_json(json).unwrap();
5590        normalize_user(event.value_mut().as_mut().unwrap());
5591
5592        let user = event.value().unwrap().user.value().unwrap();
5593        assert_eq!(user.data, {
5594            let mut map = Object::new();
5595            map.insert(
5596                "other".to_owned(),
5597                Annotated::new(Value::String("value".to_owned())),
5598            );
5599            Annotated::new(map)
5600        });
5601        assert_eq!(user.other, Object::new());
5602        assert_eq!(user.username, Annotated::new("john".to_owned().into()));
5603        assert_eq!(user.sentry_user, Annotated::new("id:123456".to_owned()));
5604    }
5605
5606    #[test]
5607    fn test_handle_types_in_spaced_exception_values() {
5608        let mut exception = Annotated::new(Exception {
5609            value: Annotated::new("ValueError: unauthorized".to_owned().into()),
5610            ..Exception::default()
5611        });
5612        normalize_exception(&mut exception);
5613
5614        let exception = exception.value().unwrap();
5615        assert_eq!(exception.value.as_str(), Some("unauthorized"));
5616        assert_eq!(exception.ty.as_str(), Some("ValueError"));
5617    }
5618
5619    #[test]
5620    fn test_handle_types_in_non_spaced_excepton_values() {
5621        let mut exception = Annotated::new(Exception {
5622            value: Annotated::new("ValueError:unauthorized".to_owned().into()),
5623            ..Exception::default()
5624        });
5625        normalize_exception(&mut exception);
5626
5627        let exception = exception.value().unwrap();
5628        assert_eq!(exception.value.as_str(), Some("unauthorized"));
5629        assert_eq!(exception.ty.as_str(), Some("ValueError"));
5630    }
5631
5632    #[test]
5633    fn test_rejects_empty_exception_fields() {
5634        let mut exception = Annotated::new(Exception {
5635            value: Annotated::new("".to_owned().into()),
5636            ty: Annotated::new("".to_owned()),
5637            ..Default::default()
5638        });
5639
5640        normalize_exception(&mut exception);
5641
5642        assert!(exception.value().is_none());
5643        assert!(exception.meta().has_errors());
5644    }
5645
5646    #[test]
5647    fn test_json_value() {
5648        let mut exception = Annotated::new(Exception {
5649            value: Annotated::new(r#"{"unauthorized":true}"#.to_owned().into()),
5650            ..Exception::default()
5651        });
5652
5653        normalize_exception(&mut exception);
5654
5655        let exception = exception.value().unwrap();
5656
5657        // Don't split a json-serialized value on the colon
5658        assert_eq!(exception.value.as_str(), Some(r#"{"unauthorized":true}"#));
5659        assert_eq!(exception.ty.value(), None);
5660    }
5661
5662    #[test]
5663    fn test_exception_invalid() {
5664        let mut exception = Annotated::new(Exception::default());
5665
5666        normalize_exception(&mut exception);
5667
5668        let expected = Error::with(ErrorKind::MissingAttribute, |error| {
5669            error.insert("attribute", "type or value");
5670        });
5671        assert_eq!(
5672            exception.meta().iter_errors().collect_tuple(),
5673            Some((&expected,))
5674        );
5675    }
5676
5677    #[test]
5678    fn test_normalize_exception() {
5679        let mut event = Annotated::new(Event {
5680            exceptions: Annotated::new(Values::new(vec![Annotated::new(Exception {
5681                // Exception with missing type and value
5682                ty: Annotated::empty(),
5683                value: Annotated::empty(),
5684                ..Default::default()
5685            })])),
5686            ..Default::default()
5687        });
5688
5689        normalize_event(&mut event, &NormalizationConfig::default());
5690
5691        let exception = event
5692            .value()
5693            .unwrap()
5694            .exceptions
5695            .value()
5696            .unwrap()
5697            .values
5698            .value()
5699            .unwrap()
5700            .first()
5701            .unwrap();
5702
5703        assert_debug_snapshot!(exception.meta(), @r###"
5704        Meta {
5705            remarks: [],
5706            errors: [
5707                Error {
5708                    kind: MissingAttribute,
5709                    data: {
5710                        "attribute": String(
5711                            "type or value",
5712                        ),
5713                    },
5714                },
5715            ],
5716            original_length: None,
5717            original_value: Some(
5718                Object(
5719                    {
5720                        "mechanism": ~,
5721                        "module": ~,
5722                        "raw_stacktrace": ~,
5723                        "stacktrace": ~,
5724                        "thread_id": ~,
5725                        "type": ~,
5726                        "value": ~,
5727                    },
5728                ),
5729            ),
5730        }
5731        "###);
5732    }
5733
5734    #[test]
5735    fn test_normalize_breadcrumbs() {
5736        let mut event = Event {
5737            breadcrumbs: Annotated::new(Values {
5738                values: Annotated::new(vec![Annotated::new(Breadcrumb::default())]),
5739                ..Default::default()
5740            }),
5741            ..Default::default()
5742        };
5743        normalize_breadcrumbs(&mut event);
5744
5745        let breadcrumb = event
5746            .breadcrumbs
5747            .value()
5748            .unwrap()
5749            .values
5750            .value()
5751            .unwrap()
5752            .first()
5753            .unwrap()
5754            .value()
5755            .unwrap();
5756        assert_eq!(breadcrumb.ty.value().unwrap(), "default");
5757        assert_eq!(&breadcrumb.level.value().unwrap().to_string(), "info");
5758    }
5759
5760    #[test]
5761    fn test_other_debug_images_have_meta_errors() {
5762        let mut event = Event {
5763            debug_meta: Annotated::new(DebugMeta {
5764                images: Annotated::new(vec![Annotated::new(
5765                    DebugImage::Other(BTreeMap::default()),
5766                )]),
5767                ..Default::default()
5768            }),
5769            ..Default::default()
5770        };
5771        normalize_debug_meta(&mut event);
5772
5773        let debug_image_meta = event
5774            .debug_meta
5775            .value()
5776            .unwrap()
5777            .images
5778            .value()
5779            .unwrap()
5780            .first()
5781            .unwrap()
5782            .meta();
5783        assert_debug_snapshot!(debug_image_meta, @r###"
5784        Meta {
5785            remarks: [],
5786            errors: [
5787                Error {
5788                    kind: InvalidData,
5789                    data: {
5790                        "reason": String(
5791                            "unsupported debug image type",
5792                        ),
5793                    },
5794                },
5795            ],
5796            original_length: None,
5797            original_value: Some(
5798                Object(
5799                    {},
5800                ),
5801            ),
5802        }
5803        "###);
5804    }
5805
5806    #[test]
5807    fn test_skip_span_normalization_when_configured() {
5808        let json = r#"{
5809            "type": "transaction",
5810            "start_timestamp": 1,
5811            "timestamp": 2,
5812            "contexts": {
5813                "trace": {
5814                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
5815                    "span_id": "aaaaaaaaaaaaaaaa"
5816                }
5817            },
5818            "spans": [
5819                {
5820                    "op": "db",
5821                    "description": "SELECT * FROM table;",
5822                    "start_timestamp": 1,
5823                    "timestamp": 2,
5824                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
5825                    "span_id": "bbbbbbbbbbbbbbbb",
5826                    "parent_span_id": "aaaaaaaaaaaaaaaa"
5827                }
5828            ]
5829        }"#;
5830
5831        let mut event = Annotated::<Event>::from_json(json).unwrap();
5832        assert!(get_value!(event.spans[0].exclusive_time).is_none());
5833        normalize_event(
5834            &mut event,
5835            &NormalizationConfig {
5836                is_renormalize: true,
5837                ..Default::default()
5838            },
5839        );
5840        assert!(get_value!(event.spans[0].exclusive_time).is_none());
5841        normalize_event(
5842            &mut event,
5843            &NormalizationConfig {
5844                is_renormalize: false,
5845                ..Default::default()
5846            },
5847        );
5848        assert!(get_value!(event.spans[0].exclusive_time).is_some());
5849    }
5850
5851    #[test]
5852    fn test_normalize_trace_context_tags_extracts_lcp_info() {
5853        let json = r#"{
5854            "type": "transaction",
5855            "start_timestamp": 1,
5856            "timestamp": 2,
5857            "contexts": {
5858                "trace": {
5859                    "data": {
5860                        "browser.web_vital.lcp.element": "body > div#app > div > h1#header",
5861                        "browser.web_vital.lcp.size": 24827,
5862                        "browser.web_vital.lcp.id": "header",
5863                        "browser.web_vital.lcp.url": "http://example.com/image.jpg"
5864                    }
5865                }
5866            },
5867            "measurements": {
5868                "lcp": { "value": 146.20000000298023, "unit": "millisecond" }
5869            }
5870        }"#;
5871        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5872        normalize_trace_context_tags(&mut event);
5873        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5874        {
5875          "type": "transaction",
5876          "timestamp": 2.0,
5877          "start_timestamp": 1.0,
5878          "contexts": {
5879            "trace": {
5880              "data": {
5881                "browser.web_vital.lcp.element": "body > div#app > div > h1#header",
5882                "browser.web_vital.lcp.id": "header",
5883                "browser.web_vital.lcp.size": 24827,
5884                "browser.web_vital.lcp.url": "http://example.com/image.jpg",
5885              },
5886              "type": "trace",
5887            },
5888          },
5889          "tags": [
5890            [
5891              "lcp.element",
5892              "body > div#app > div > h1#header",
5893            ],
5894            [
5895              "lcp.size",
5896              "24827",
5897            ],
5898            [
5899              "lcp.id",
5900              "header",
5901            ],
5902            [
5903              "lcp.url",
5904              "http://example.com/image.jpg",
5905            ],
5906          ],
5907          "measurements": {
5908            "lcp": {
5909              "value": 146.20000000298023,
5910              "unit": "millisecond",
5911            },
5912          },
5913        }
5914        "###);
5915    }
5916
5917    #[test]
5918    fn test_normalize_trace_context_tags_does_not_overwrite_lcp_tags() {
5919        let json = r#"{
5920          "type": "transaction",
5921          "start_timestamp": 1,
5922          "timestamp": 2,
5923          "contexts": {
5924              "trace": {
5925                  "data": {
5926                      "browser.web_vital.lcp.element": "body > div#app > div > h1#id",
5927                      "browser.web_vital.lcp.size": 33333,
5928                      "browser.web_vital.lcp.id": "id",
5929                      "browser.web_vital.lcp.url": "http://example.com/another-image.jpg"
5930                  }
5931              }
5932          },
5933          "tags": {
5934              "lcp.element": "body > div#app > div > h1#header",
5935              "lcp.size": 24827,
5936              "lcp.id": "header",
5937              "lcp.url": "http://example.com/image.jpg"
5938          },
5939          "measurements": {
5940              "lcp": { "value": 146.20000000298023, "unit": "millisecond" }
5941          }
5942        }"#;
5943        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5944        normalize_trace_context_tags(&mut event);
5945        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5946        {
5947          "type": "transaction",
5948          "timestamp": 2.0,
5949          "start_timestamp": 1.0,
5950          "contexts": {
5951            "trace": {
5952              "data": {
5953                "browser.web_vital.lcp.element": "body > div#app > div > h1#id",
5954                "browser.web_vital.lcp.id": "id",
5955                "browser.web_vital.lcp.size": 33333,
5956                "browser.web_vital.lcp.url": "http://example.com/another-image.jpg",
5957              },
5958              "type": "trace",
5959            },
5960          },
5961          "tags": [
5962            [
5963              "lcp.element",
5964              "body > div#app > div > h1#header",
5965            ],
5966            [
5967              "lcp.id",
5968              "header",
5969            ],
5970            [
5971              "lcp.size",
5972              "24827",
5973            ],
5974            [
5975              "lcp.url",
5976              "http://example.com/image.jpg",
5977            ],
5978          ],
5979          "measurements": {
5980            "lcp": {
5981              "value": 146.20000000298023,
5982              "unit": "millisecond",
5983            },
5984          },
5985        }
5986        "###);
5987    }
5988
5989    #[test]
5990    fn test_tags_are_trimmed() {
5991        let json = r#"
5992            {
5993                "tags": {
5994                    "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_",
5995                    "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"
5996                }
5997            }
5998        "#;
5999
6000        let mut event = Annotated::<Event>::from_json(json).unwrap();
6001
6002        normalize_event(
6003            &mut event,
6004            &NormalizationConfig {
6005                enable_trimming: true,
6006                ..NormalizationConfig::default()
6007            },
6008        );
6009
6010        insta::assert_debug_snapshot!(get_value!(event.tags!), @r###"
6011        Tags(
6012            PairList(
6013                [
6014                    TagEntry(
6015                        "key",
6016                        Annotated(
6017                            "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...",
6018                            Meta {
6019                                remarks: [
6020                                    Remark {
6021                                        ty: Substituted,
6022                                        rule_id: "!limit",
6023                                        range: Some(
6024                                            (
6025                                                197,
6026                                                200,
6027                                            ),
6028                                        ),
6029                                    },
6030                                ],
6031                                errors: [],
6032                                original_length: Some(
6033                                    210,
6034                                ),
6035                                original_value: None,
6036                            },
6037                        ),
6038                    ),
6039                    TagEntry(
6040                        Annotated(
6041                            "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...",
6042                            Meta {
6043                                remarks: [
6044                                    Remark {
6045                                        ty: Substituted,
6046                                        rule_id: "!limit",
6047                                        range: Some(
6048                                            (
6049                                                197,
6050                                                200,
6051                                            ),
6052                                        ),
6053                                    },
6054                                ],
6055                                errors: [],
6056                                original_length: Some(
6057                                    210,
6058                                ),
6059                                original_value: None,
6060                            },
6061                        ),
6062                        "value",
6063                    ),
6064                ],
6065            ),
6066        )
6067        "###);
6068    }
6069}