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