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