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