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.cost.total_tokens": 50.0,
2333          "gen_ai.cost.input_tokens": 10.0,
2334          "gen_ai.cost.output_tokens": 40.0,
2335          "gen_ai.response.tokens_per_second": 62500.0
2336        }
2337        "#);
2338        assert_annotated_snapshot!(span2, @r#"
2339        {
2340          "gen_ai.usage.total_tokens": 3000.0,
2341          "gen_ai.usage.input_tokens": 1000.0,
2342          "gen_ai.usage.output_tokens": 2000.0,
2343          "gen_ai.request.model": "gpt4-21-04",
2344          "gen_ai.cost.total_tokens": 80.0,
2345          "gen_ai.cost.input_tokens": 20.0,
2346          "gen_ai.cost.output_tokens": 60.0,
2347          "gen_ai.response.tokens_per_second": 62500.0
2348        }
2349        "#);
2350    }
2351
2352    #[test]
2353    fn test_ai_data() {
2354        let json = r#"
2355            {
2356                "spans": [
2357                    {
2358                        "timestamp": 1702474614.0175,
2359                        "start_timestamp": 1702474613.0175,
2360                        "description": "OpenAI ",
2361                        "op": "gen_ai.chat_completions.openai",
2362                        "span_id": "9c01bd820a083e63",
2363                        "parent_span_id": "a1e13f3f06239d69",
2364                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2365                        "data": {
2366                            "gen_ai.usage.input_tokens": 1000,
2367                            "gen_ai.usage.output_tokens": 2000,
2368                            "gen_ai.usage.output_tokens.reasoning": 1000,
2369                            "gen_ai.usage.input_tokens.cached": 500,
2370                            "gen_ai.request.model": "claude-2.1"
2371                        }
2372                    },
2373                    {
2374                        "timestamp": 1702474614.0175,
2375                        "start_timestamp": 1702474613.0175,
2376                        "description": "OpenAI ",
2377                        "op": "gen_ai.chat_completions.openai",
2378                        "span_id": "ac01bd820a083e63",
2379                        "parent_span_id": "a1e13f3f06239d69",
2380                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2381                        "data": {
2382                            "gen_ai.usage.input_tokens": 1000,
2383                            "gen_ai.usage.output_tokens": 2000,
2384                            "gen_ai.request.model": "gpt4-21-04"
2385                        }
2386                    },
2387                    {
2388                        "timestamp": 1702474614.0175,
2389                        "start_timestamp": 1702474613.0175,
2390                        "description": "OpenAI ",
2391                        "op": "gen_ai.chat_completions.openai",
2392                        "span_id": "ac01bd820a083e63",
2393                        "parent_span_id": "a1e13f3f06239d69",
2394                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2395                        "data": {
2396                            "gen_ai.usage.input_tokens": 1000,
2397                            "gen_ai.usage.output_tokens": 2000,
2398                            "gen_ai.response.model": "gpt4-21-04"
2399                        }
2400                    }
2401                ]
2402            }
2403        "#;
2404
2405        let mut event = Annotated::<Event>::from_json(json).unwrap();
2406
2407        normalize_event(
2408            &mut event,
2409            &NormalizationConfig {
2410                ai_model_costs: Some(&ModelCosts {
2411                    version: 2,
2412                    models: HashMap::from([
2413                        (
2414                            Pattern::new("claude-2.1").unwrap(),
2415                            ModelCostV2 {
2416                                input_per_token: 0.01,
2417                                output_per_token: 0.02,
2418                                output_reasoning_per_token: 0.03,
2419                                input_cached_per_token: 0.04,
2420                            },
2421                        ),
2422                        (
2423                            Pattern::new("gpt4-21-04").unwrap(),
2424                            ModelCostV2 {
2425                                input_per_token: 0.09,
2426                                output_per_token: 0.05,
2427                                output_reasoning_per_token: 0.0,
2428                                input_cached_per_token: 0.0,
2429                            },
2430                        ),
2431                    ]),
2432                }),
2433                ..NormalizationConfig::default()
2434            },
2435        );
2436
2437        let [span1, span2, span3] = collect_span_data(event);
2438
2439        assert_annotated_snapshot!(span1, @r#"
2440        {
2441          "gen_ai.usage.total_tokens": 3000.0,
2442          "gen_ai.usage.input_tokens": 1000,
2443          "gen_ai.usage.input_tokens.cached": 500,
2444          "gen_ai.usage.output_tokens": 2000,
2445          "gen_ai.usage.output_tokens.reasoning": 1000,
2446          "gen_ai.request.model": "claude-2.1",
2447          "gen_ai.cost.total_tokens": 75.0,
2448          "gen_ai.cost.input_tokens": 25.0,
2449          "gen_ai.cost.output_tokens": 50.0,
2450          "gen_ai.response.tokens_per_second": 2000.0
2451        }
2452        "#);
2453        assert_annotated_snapshot!(span2, @r#"
2454        {
2455          "gen_ai.usage.total_tokens": 3000.0,
2456          "gen_ai.usage.input_tokens": 1000,
2457          "gen_ai.usage.output_tokens": 2000,
2458          "gen_ai.request.model": "gpt4-21-04",
2459          "gen_ai.cost.total_tokens": 190.0,
2460          "gen_ai.cost.input_tokens": 90.0,
2461          "gen_ai.cost.output_tokens": 100.0,
2462          "gen_ai.response.tokens_per_second": 2000.0
2463        }
2464        "#);
2465        assert_annotated_snapshot!(span3, @r#"
2466        {
2467          "gen_ai.usage.total_tokens": 3000.0,
2468          "gen_ai.usage.input_tokens": 1000,
2469          "gen_ai.usage.output_tokens": 2000,
2470          "gen_ai.response.model": "gpt4-21-04",
2471          "gen_ai.cost.total_tokens": 190.0,
2472          "gen_ai.cost.input_tokens": 90.0,
2473          "gen_ai.cost.output_tokens": 100.0,
2474          "gen_ai.response.tokens_per_second": 2000.0
2475        }
2476        "#);
2477    }
2478
2479    #[test]
2480    fn test_ai_data_with_no_tokens() {
2481        let json = r#"
2482            {
2483                "spans": [
2484                    {
2485                        "timestamp": 1702474613.0495,
2486                        "start_timestamp": 1702474613.0175,
2487                        "description": "OpenAI ",
2488                        "op": "gen_ai.invoke_agent",
2489                        "span_id": "9c01bd820a083e63",
2490                        "parent_span_id": "a1e13f3f06239d69",
2491                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2492                        "data": {
2493                            "gen_ai.request.model": "claude-2.1"
2494                        }
2495                    }
2496                ]
2497            }
2498        "#;
2499
2500        let mut event = Annotated::<Event>::from_json(json).unwrap();
2501
2502        normalize_event(
2503            &mut event,
2504            &NormalizationConfig {
2505                ai_model_costs: Some(&ModelCosts {
2506                    version: 2,
2507                    models: HashMap::from([(
2508                        Pattern::new("claude-2.1").unwrap(),
2509                        ModelCostV2 {
2510                            input_per_token: 0.01,
2511                            output_per_token: 0.02,
2512                            output_reasoning_per_token: 0.03,
2513                            input_cached_per_token: 0.0,
2514                        },
2515                    )]),
2516                }),
2517                ..NormalizationConfig::default()
2518            },
2519        );
2520
2521        let [span] = collect_span_data(event);
2522
2523        assert_annotated_snapshot!(span, @r#"
2524        {
2525          "gen_ai.request.model": "claude-2.1"
2526        }
2527        "#);
2528    }
2529
2530    #[test]
2531    fn test_ai_data_with_ai_op_prefix() {
2532        let json = r#"
2533            {
2534                "spans": [
2535                    {
2536                        "timestamp": 1702474613.0495,
2537                        "start_timestamp": 1702474613.0175,
2538                        "description": "OpenAI ",
2539                        "op": "ai.chat_completions.openai",
2540                        "span_id": "9c01bd820a083e63",
2541                        "parent_span_id": "a1e13f3f06239d69",
2542                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2543                        "data": {
2544                            "gen_ai.usage.input_tokens": 1000,
2545                            "gen_ai.usage.output_tokens": 2000,
2546                            "gen_ai.usage.output_tokens.reasoning": 1000,
2547                            "gen_ai.usage.input_tokens.cached": 500,
2548                            "gen_ai.request.model": "claude-2.1"
2549                        }
2550                    },
2551                    {
2552                        "timestamp": 1702474613.0495,
2553                        "start_timestamp": 1702474613.0175,
2554                        "description": "OpenAI ",
2555                        "op": "ai.chat_completions.openai",
2556                        "span_id": "ac01bd820a083e63",
2557                        "parent_span_id": "a1e13f3f06239d69",
2558                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2559                        "data": {
2560                            "gen_ai.usage.input_tokens": 1000,
2561                            "gen_ai.usage.output_tokens": 2000,
2562                            "gen_ai.request.model": "gpt4-21-04"
2563                        }
2564                    }
2565                ]
2566            }
2567        "#;
2568
2569        let mut event = Annotated::<Event>::from_json(json).unwrap();
2570
2571        normalize_event(
2572            &mut event,
2573            &NormalizationConfig {
2574                ai_model_costs: Some(&ModelCosts {
2575                    version: 2,
2576                    models: HashMap::from([
2577                        (
2578                            Pattern::new("claude-2.1").unwrap(),
2579                            ModelCostV2 {
2580                                input_per_token: 0.01,
2581                                output_per_token: 0.02,
2582                                output_reasoning_per_token: 0.0,
2583                                input_cached_per_token: 0.04,
2584                            },
2585                        ),
2586                        (
2587                            Pattern::new("gpt4-21-04").unwrap(),
2588                            ModelCostV2 {
2589                                input_per_token: 0.09,
2590                                output_per_token: 0.05,
2591                                output_reasoning_per_token: 0.06,
2592                                input_cached_per_token: 0.0,
2593                            },
2594                        ),
2595                    ]),
2596                }),
2597                ..NormalizationConfig::default()
2598            },
2599        );
2600
2601        let [span1, span2] = collect_span_data(event);
2602
2603        assert_annotated_snapshot!(span1, @r#"
2604        {
2605          "gen_ai.usage.total_tokens": 3000.0,
2606          "gen_ai.usage.input_tokens": 1000,
2607          "gen_ai.usage.input_tokens.cached": 500,
2608          "gen_ai.usage.output_tokens": 2000,
2609          "gen_ai.usage.output_tokens.reasoning": 1000,
2610          "gen_ai.request.model": "claude-2.1",
2611          "gen_ai.cost.total_tokens": 65.0,
2612          "gen_ai.cost.input_tokens": 25.0,
2613          "gen_ai.cost.output_tokens": 40.0,
2614          "gen_ai.response.tokens_per_second": 62500.0
2615        }
2616        "#);
2617        assert_annotated_snapshot!(span2, @r#"
2618        {
2619          "gen_ai.usage.total_tokens": 3000.0,
2620          "gen_ai.usage.input_tokens": 1000,
2621          "gen_ai.usage.output_tokens": 2000,
2622          "gen_ai.request.model": "gpt4-21-04",
2623          "gen_ai.cost.total_tokens": 190.0,
2624          "gen_ai.cost.input_tokens": 90.0,
2625          "gen_ai.cost.output_tokens": 100.0,
2626          "gen_ai.response.tokens_per_second": 62500.0
2627        }
2628        "#);
2629    }
2630
2631    #[test]
2632    fn test_ai_response_tokens_per_second_no_output_tokens() {
2633        let json = r#"
2634            {
2635                "spans": [
2636                    {
2637                        "timestamp": 1702474614.0175,
2638                        "start_timestamp": 1702474613.0175,
2639                        "op": "gen_ai.chat_completions",
2640                        "span_id": "9c01bd820a083e63",
2641                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2642                        "data": {
2643                            "gen_ai.usage.input_tokens": 500
2644                        }
2645                    }
2646                ]
2647            }
2648        "#;
2649
2650        let mut event = Annotated::<Event>::from_json(json).unwrap();
2651
2652        normalize_event(
2653            &mut event,
2654            &NormalizationConfig {
2655                ai_model_costs: Some(&ModelCosts {
2656                    version: 2,
2657                    models: HashMap::new(),
2658                }),
2659                ..NormalizationConfig::default()
2660            },
2661        );
2662
2663        let [span] = collect_span_data(event);
2664
2665        // Should not set response_tokens_per_second when there are no output tokens
2666        assert_annotated_snapshot!(span, @r#"
2667        {
2668          "gen_ai.usage.total_tokens": 500.0,
2669          "gen_ai.usage.input_tokens": 500
2670        }
2671        "#);
2672    }
2673
2674    #[test]
2675    fn test_ai_response_tokens_per_second_zero_duration() {
2676        let json = r#"
2677            {
2678                "spans": [
2679                    {
2680                        "timestamp": 1702474613.0175,
2681                        "start_timestamp": 1702474613.0175,
2682                        "op": "gen_ai.chat_completions",
2683                        "span_id": "9c01bd820a083e63",
2684                        "trace_id": "922dda2462ea4ac2b6a4b339bee90863",
2685                        "data": {
2686                            "gen_ai.usage.output_tokens": 1000
2687                        }
2688                    }
2689                ]
2690            }
2691        "#;
2692
2693        let mut event = Annotated::<Event>::from_json(json).unwrap();
2694
2695        normalize_event(
2696            &mut event,
2697            &NormalizationConfig {
2698                ai_model_costs: Some(&ModelCosts {
2699                    version: 2,
2700                    models: HashMap::new(),
2701                }),
2702                ..NormalizationConfig::default()
2703            },
2704        );
2705
2706        let [span] = collect_span_data(event);
2707
2708        // Should not set response_tokens_per_second when duration is zero
2709        assert_annotated_snapshot!(span, @r#"
2710        {
2711          "gen_ai.usage.total_tokens": 1000.0,
2712          "gen_ai.usage.output_tokens": 1000
2713        }
2714        "#);
2715    }
2716
2717    #[test]
2718    fn test_ai_operation_type_mapping() {
2719        let json = r#"
2720            {
2721                "type": "transaction",
2722                "transaction": "test-transaction",
2723                "spans": [
2724                    {
2725                        "op": "gen_ai.chat",
2726                        "description": "AI chat completion",
2727                        "data": {}
2728                    },
2729                    {
2730                        "op": "gen_ai.handoff",
2731                        "description": "AI agent handoff",
2732                        "data": {}
2733                    },
2734                    {
2735                        "op": "gen_ai.unknown",
2736                        "description": "Unknown AI operation",
2737                        "data": {}
2738                    }
2739                ]
2740            }
2741        "#;
2742
2743        let mut event = Annotated::<Event>::from_json(json).unwrap();
2744
2745        let operation_type_map = AiOperationTypeMap {
2746            version: 1,
2747            operation_types: HashMap::from([
2748                (Pattern::new("gen_ai.chat").unwrap(), "chat".to_owned()),
2749                (
2750                    Pattern::new("gen_ai.execute_tool").unwrap(),
2751                    "execute_tool".to_owned(),
2752                ),
2753                (
2754                    Pattern::new("gen_ai.handoff").unwrap(),
2755                    "handoff".to_owned(),
2756                ),
2757                (
2758                    Pattern::new("gen_ai.invoke_agent").unwrap(),
2759                    "invoke_agent".to_owned(),
2760                ),
2761                // fallback to agent
2762                (Pattern::new("gen_ai.*").unwrap(), "agent".to_owned()),
2763            ]),
2764        };
2765
2766        normalize_event(
2767            &mut event,
2768            &NormalizationConfig {
2769                ai_operation_type_map: Some(&operation_type_map),
2770                ..NormalizationConfig::default()
2771            },
2772        );
2773
2774        let [span1, span2, span3] = collect_span_data(event);
2775
2776        assert_annotated_snapshot!(span1, @r#"
2777        {
2778          "gen_ai.operation.type": "chat"
2779        }
2780        "#);
2781        assert_annotated_snapshot!(span2, @r#"
2782        {
2783          "gen_ai.operation.type": "handoff"
2784        }
2785        "#);
2786        assert_annotated_snapshot!(span3, @r#"
2787        {
2788          "gen_ai.operation.type": "agent"
2789        }
2790        "#);
2791    }
2792
2793    #[test]
2794    fn test_ai_operation_type_disabled_map() {
2795        let json = r#"
2796            {
2797                "type": "transaction",
2798                "transaction": "test-transaction",
2799                "spans": [
2800                    {
2801                        "op": "gen_ai.chat",
2802                        "description": "AI chat completion",
2803                        "data": {}
2804                    }
2805                ]
2806            }
2807        "#;
2808
2809        let mut event = Annotated::<Event>::from_json(json).unwrap();
2810
2811        let operation_type_map = AiOperationTypeMap {
2812            version: 0, // Disabled version
2813            operation_types: HashMap::from([(
2814                Pattern::new("gen_ai.chat").unwrap(),
2815                "chat".to_owned(),
2816            )]),
2817        };
2818
2819        normalize_event(
2820            &mut event,
2821            &NormalizationConfig {
2822                ai_operation_type_map: Some(&operation_type_map),
2823                ..NormalizationConfig::default()
2824            },
2825        );
2826
2827        let [span] = collect_span_data(event);
2828
2829        // Should not set operation type when map is disabled
2830        assert_annotated_snapshot!(span, @"{}");
2831    }
2832
2833    #[test]
2834    fn test_ai_operation_type_empty_map() {
2835        let json = r#"
2836            {
2837                "type": "transaction",
2838                "transaction": "test-transaction",
2839                "spans": [
2840                    {
2841                        "op": "gen_ai.chat",
2842                        "description": "AI chat completion",
2843                        "data": {}
2844                    }
2845                ]
2846            }
2847        "#;
2848
2849        let mut event = Annotated::<Event>::from_json(json).unwrap();
2850
2851        let operation_type_map = AiOperationTypeMap {
2852            version: 1,
2853            operation_types: HashMap::new(),
2854        };
2855
2856        normalize_event(
2857            &mut event,
2858            &NormalizationConfig {
2859                ai_operation_type_map: Some(&operation_type_map),
2860                ..NormalizationConfig::default()
2861            },
2862        );
2863
2864        let [span] = collect_span_data(event);
2865
2866        // Should not set operation type when map is empty
2867        assert_annotated_snapshot!(span, @"{}");
2868    }
2869
2870    #[test]
2871    fn test_apple_high_device_class() {
2872        let mut event = Event {
2873            contexts: {
2874                let mut contexts = Contexts::new();
2875                contexts.add(DeviceContext {
2876                    family: "iPhone".to_owned().into(),
2877                    model: "iPhone15,3".to_owned().into(),
2878                    ..Default::default()
2879                });
2880                Annotated::new(contexts)
2881            },
2882            ..Default::default()
2883        };
2884        normalize_device_class(&mut event);
2885        assert_debug_snapshot!(event.tags, @r###"
2886        Tags(
2887            PairList(
2888                [
2889                    TagEntry(
2890                        "device.class",
2891                        "3",
2892                    ),
2893                ],
2894            ),
2895        )
2896        "###);
2897    }
2898
2899    #[test]
2900    fn test_filter_mobile_outliers() {
2901        let mut measurements =
2902            Annotated::<Measurements>::from_json(r#"{"app_start_warm": {"value": 180001}}"#)
2903                .unwrap()
2904                .into_value()
2905                .unwrap();
2906        assert_eq!(measurements.len(), 1);
2907        filter_mobile_outliers(&mut measurements);
2908        assert_eq!(measurements.len(), 0);
2909    }
2910
2911    #[test]
2912    fn test_computed_performance_score() {
2913        let json = r#"
2914        {
2915            "type": "transaction",
2916            "timestamp": "2021-04-26T08:00:05+0100",
2917            "start_timestamp": "2021-04-26T08:00:00+0100",
2918            "measurements": {
2919                "fid": {"value": 213, "unit": "millisecond"},
2920                "fcp": {"value": 1237, "unit": "millisecond"},
2921                "lcp": {"value": 6596, "unit": "millisecond"},
2922                "cls": {"value": 0.11}
2923            },
2924            "contexts": {
2925                "browser": {
2926                    "name": "Chrome",
2927                    "version": "120.1.1",
2928                    "type": "browser"
2929                }
2930            }
2931        }
2932        "#;
2933
2934        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
2935
2936        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
2937            "profiles": [
2938                {
2939                    "name": "Desktop",
2940                    "scoreComponents": [
2941                        {
2942                            "measurement": "fcp",
2943                            "weight": 0.15,
2944                            "p10": 900,
2945                            "p50": 1600
2946                        },
2947                        {
2948                            "measurement": "lcp",
2949                            "weight": 0.30,
2950                            "p10": 1200,
2951                            "p50": 2400
2952                        },
2953                        {
2954                            "measurement": "fid",
2955                            "weight": 0.30,
2956                            "p10": 100,
2957                            "p50": 300
2958                        },
2959                        {
2960                            "measurement": "cls",
2961                            "weight": 0.25,
2962                            "p10": 0.1,
2963                            "p50": 0.25
2964                        },
2965                        {
2966                            "measurement": "ttfb",
2967                            "weight": 0.0,
2968                            "p10": 0.2,
2969                            "p50": 0.4
2970                        },
2971                    ],
2972                    "condition": {
2973                        "op":"eq",
2974                        "name": "event.contexts.browser.name",
2975                        "value": "Chrome"
2976                    }
2977                }
2978            ]
2979        }))
2980        .unwrap();
2981
2982        normalize_performance_score(&mut event, Some(&performance_score));
2983
2984        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
2985        {
2986          "type": "transaction",
2987          "timestamp": 1619420405.0,
2988          "start_timestamp": 1619420400.0,
2989          "contexts": {
2990            "browser": {
2991              "name": "Chrome",
2992              "version": "120.1.1",
2993              "type": "browser",
2994            },
2995          },
2996          "measurements": {
2997            "cls": {
2998              "value": 0.11,
2999            },
3000            "fcp": {
3001              "value": 1237.0,
3002              "unit": "millisecond",
3003            },
3004            "fid": {
3005              "value": 213.0,
3006              "unit": "millisecond",
3007            },
3008            "lcp": {
3009              "value": 6596.0,
3010              "unit": "millisecond",
3011            },
3012            "score.cls": {
3013              "value": 0.21864170607444863,
3014              "unit": "ratio",
3015            },
3016            "score.fcp": {
3017              "value": 0.10750855443790831,
3018              "unit": "ratio",
3019            },
3020            "score.fid": {
3021              "value": 0.19657361348282545,
3022              "unit": "ratio",
3023            },
3024            "score.lcp": {
3025              "value": 0.009238896571386584,
3026              "unit": "ratio",
3027            },
3028            "score.ratio.cls": {
3029              "value": 0.8745668242977945,
3030              "unit": "ratio",
3031            },
3032            "score.ratio.fcp": {
3033              "value": 0.7167236962527221,
3034              "unit": "ratio",
3035            },
3036            "score.ratio.fid": {
3037              "value": 0.6552453782760849,
3038              "unit": "ratio",
3039            },
3040            "score.ratio.lcp": {
3041              "value": 0.03079632190462195,
3042              "unit": "ratio",
3043            },
3044            "score.total": {
3045              "value": 0.531962770566569,
3046              "unit": "ratio",
3047            },
3048            "score.weight.cls": {
3049              "value": 0.25,
3050              "unit": "ratio",
3051            },
3052            "score.weight.fcp": {
3053              "value": 0.15,
3054              "unit": "ratio",
3055            },
3056            "score.weight.fid": {
3057              "value": 0.3,
3058              "unit": "ratio",
3059            },
3060            "score.weight.lcp": {
3061              "value": 0.3,
3062              "unit": "ratio",
3063            },
3064            "score.weight.ttfb": {
3065              "value": 0.0,
3066              "unit": "ratio",
3067            },
3068          },
3069        }
3070        "###);
3071    }
3072
3073    // Test performance score is calculated correctly when the sum of weights is under 1.
3074    // The expected result should normalize the weights to a sum of 1 and scale the weight measurements accordingly.
3075    #[test]
3076    fn test_computed_performance_score_with_under_normalized_weights() {
3077        let json = r#"
3078        {
3079            "type": "transaction",
3080            "timestamp": "2021-04-26T08:00:05+0100",
3081            "start_timestamp": "2021-04-26T08:00:00+0100",
3082            "measurements": {
3083                "fid": {"value": 213, "unit": "millisecond"},
3084                "fcp": {"value": 1237, "unit": "millisecond"},
3085                "lcp": {"value": 6596, "unit": "millisecond"},
3086                "cls": {"value": 0.11}
3087            },
3088            "contexts": {
3089                "browser": {
3090                    "name": "Chrome",
3091                    "version": "120.1.1",
3092                    "type": "browser"
3093                }
3094            }
3095        }
3096        "#;
3097
3098        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3099
3100        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3101            "profiles": [
3102                {
3103                    "name": "Desktop",
3104                    "scoreComponents": [
3105                        {
3106                            "measurement": "fcp",
3107                            "weight": 0.03,
3108                            "p10": 900,
3109                            "p50": 1600
3110                        },
3111                        {
3112                            "measurement": "lcp",
3113                            "weight": 0.06,
3114                            "p10": 1200,
3115                            "p50": 2400
3116                        },
3117                        {
3118                            "measurement": "fid",
3119                            "weight": 0.06,
3120                            "p10": 100,
3121                            "p50": 300
3122                        },
3123                        {
3124                            "measurement": "cls",
3125                            "weight": 0.05,
3126                            "p10": 0.1,
3127                            "p50": 0.25
3128                        },
3129                        {
3130                            "measurement": "ttfb",
3131                            "weight": 0.0,
3132                            "p10": 0.2,
3133                            "p50": 0.4
3134                        },
3135                    ],
3136                    "condition": {
3137                        "op":"eq",
3138                        "name": "event.contexts.browser.name",
3139                        "value": "Chrome"
3140                    }
3141                }
3142            ]
3143        }))
3144        .unwrap();
3145
3146        normalize_performance_score(&mut event, Some(&performance_score));
3147
3148        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3149        {
3150          "type": "transaction",
3151          "timestamp": 1619420405.0,
3152          "start_timestamp": 1619420400.0,
3153          "contexts": {
3154            "browser": {
3155              "name": "Chrome",
3156              "version": "120.1.1",
3157              "type": "browser",
3158            },
3159          },
3160          "measurements": {
3161            "cls": {
3162              "value": 0.11,
3163            },
3164            "fcp": {
3165              "value": 1237.0,
3166              "unit": "millisecond",
3167            },
3168            "fid": {
3169              "value": 213.0,
3170              "unit": "millisecond",
3171            },
3172            "lcp": {
3173              "value": 6596.0,
3174              "unit": "millisecond",
3175            },
3176            "score.cls": {
3177              "value": 0.21864170607444863,
3178              "unit": "ratio",
3179            },
3180            "score.fcp": {
3181              "value": 0.10750855443790831,
3182              "unit": "ratio",
3183            },
3184            "score.fid": {
3185              "value": 0.19657361348282545,
3186              "unit": "ratio",
3187            },
3188            "score.lcp": {
3189              "value": 0.009238896571386584,
3190              "unit": "ratio",
3191            },
3192            "score.ratio.cls": {
3193              "value": 0.8745668242977945,
3194              "unit": "ratio",
3195            },
3196            "score.ratio.fcp": {
3197              "value": 0.7167236962527221,
3198              "unit": "ratio",
3199            },
3200            "score.ratio.fid": {
3201              "value": 0.6552453782760849,
3202              "unit": "ratio",
3203            },
3204            "score.ratio.lcp": {
3205              "value": 0.03079632190462195,
3206              "unit": "ratio",
3207            },
3208            "score.total": {
3209              "value": 0.531962770566569,
3210              "unit": "ratio",
3211            },
3212            "score.weight.cls": {
3213              "value": 0.25,
3214              "unit": "ratio",
3215            },
3216            "score.weight.fcp": {
3217              "value": 0.15,
3218              "unit": "ratio",
3219            },
3220            "score.weight.fid": {
3221              "value": 0.3,
3222              "unit": "ratio",
3223            },
3224            "score.weight.lcp": {
3225              "value": 0.3,
3226              "unit": "ratio",
3227            },
3228            "score.weight.ttfb": {
3229              "value": 0.0,
3230              "unit": "ratio",
3231            },
3232          },
3233        }
3234        "###);
3235    }
3236
3237    // Test performance score is calculated correctly when the sum of weights is over 1.
3238    // The expected result should normalize the weights to a sum of 1 and scale the weight measurements accordingly.
3239    #[test]
3240    fn test_computed_performance_score_with_over_normalized_weights() {
3241        let json = r#"
3242        {
3243            "type": "transaction",
3244            "timestamp": "2021-04-26T08:00:05+0100",
3245            "start_timestamp": "2021-04-26T08:00:00+0100",
3246            "measurements": {
3247                "fid": {"value": 213, "unit": "millisecond"},
3248                "fcp": {"value": 1237, "unit": "millisecond"},
3249                "lcp": {"value": 6596, "unit": "millisecond"},
3250                "cls": {"value": 0.11}
3251            },
3252            "contexts": {
3253                "browser": {
3254                    "name": "Chrome",
3255                    "version": "120.1.1",
3256                    "type": "browser"
3257                }
3258            }
3259        }
3260        "#;
3261
3262        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3263
3264        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3265            "profiles": [
3266                {
3267                    "name": "Desktop",
3268                    "scoreComponents": [
3269                        {
3270                            "measurement": "fcp",
3271                            "weight": 0.30,
3272                            "p10": 900,
3273                            "p50": 1600
3274                        },
3275                        {
3276                            "measurement": "lcp",
3277                            "weight": 0.60,
3278                            "p10": 1200,
3279                            "p50": 2400
3280                        },
3281                        {
3282                            "measurement": "fid",
3283                            "weight": 0.60,
3284                            "p10": 100,
3285                            "p50": 300
3286                        },
3287                        {
3288                            "measurement": "cls",
3289                            "weight": 0.50,
3290                            "p10": 0.1,
3291                            "p50": 0.25
3292                        },
3293                        {
3294                            "measurement": "ttfb",
3295                            "weight": 0.0,
3296                            "p10": 0.2,
3297                            "p50": 0.4
3298                        },
3299                    ],
3300                    "condition": {
3301                        "op":"eq",
3302                        "name": "event.contexts.browser.name",
3303                        "value": "Chrome"
3304                    }
3305                }
3306            ]
3307        }))
3308        .unwrap();
3309
3310        normalize_performance_score(&mut event, Some(&performance_score));
3311
3312        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3313        {
3314          "type": "transaction",
3315          "timestamp": 1619420405.0,
3316          "start_timestamp": 1619420400.0,
3317          "contexts": {
3318            "browser": {
3319              "name": "Chrome",
3320              "version": "120.1.1",
3321              "type": "browser",
3322            },
3323          },
3324          "measurements": {
3325            "cls": {
3326              "value": 0.11,
3327            },
3328            "fcp": {
3329              "value": 1237.0,
3330              "unit": "millisecond",
3331            },
3332            "fid": {
3333              "value": 213.0,
3334              "unit": "millisecond",
3335            },
3336            "lcp": {
3337              "value": 6596.0,
3338              "unit": "millisecond",
3339            },
3340            "score.cls": {
3341              "value": 0.21864170607444863,
3342              "unit": "ratio",
3343            },
3344            "score.fcp": {
3345              "value": 0.10750855443790831,
3346              "unit": "ratio",
3347            },
3348            "score.fid": {
3349              "value": 0.19657361348282545,
3350              "unit": "ratio",
3351            },
3352            "score.lcp": {
3353              "value": 0.009238896571386584,
3354              "unit": "ratio",
3355            },
3356            "score.ratio.cls": {
3357              "value": 0.8745668242977945,
3358              "unit": "ratio",
3359            },
3360            "score.ratio.fcp": {
3361              "value": 0.7167236962527221,
3362              "unit": "ratio",
3363            },
3364            "score.ratio.fid": {
3365              "value": 0.6552453782760849,
3366              "unit": "ratio",
3367            },
3368            "score.ratio.lcp": {
3369              "value": 0.03079632190462195,
3370              "unit": "ratio",
3371            },
3372            "score.total": {
3373              "value": 0.531962770566569,
3374              "unit": "ratio",
3375            },
3376            "score.weight.cls": {
3377              "value": 0.25,
3378              "unit": "ratio",
3379            },
3380            "score.weight.fcp": {
3381              "value": 0.15,
3382              "unit": "ratio",
3383            },
3384            "score.weight.fid": {
3385              "value": 0.3,
3386              "unit": "ratio",
3387            },
3388            "score.weight.lcp": {
3389              "value": 0.3,
3390              "unit": "ratio",
3391            },
3392            "score.weight.ttfb": {
3393              "value": 0.0,
3394              "unit": "ratio",
3395            },
3396          },
3397        }
3398        "###);
3399    }
3400
3401    #[test]
3402    fn test_computed_performance_score_missing_measurement() {
3403        let json = r#"
3404        {
3405            "type": "transaction",
3406            "timestamp": "2021-04-26T08:00:05+0100",
3407            "start_timestamp": "2021-04-26T08:00:00+0100",
3408            "measurements": {
3409                "a": {"value": 213, "unit": "millisecond"}
3410            },
3411            "contexts": {
3412                "browser": {
3413                    "name": "Chrome",
3414                    "version": "120.1.1",
3415                    "type": "browser"
3416                }
3417            }
3418        }
3419        "#;
3420
3421        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3422
3423        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3424            "profiles": [
3425                {
3426                    "name": "Desktop",
3427                    "scoreComponents": [
3428                        {
3429                            "measurement": "a",
3430                            "weight": 0.15,
3431                            "p10": 900,
3432                            "p50": 1600
3433                        },
3434                        {
3435                            "measurement": "b",
3436                            "weight": 0.30,
3437                            "p10": 1200,
3438                            "p50": 2400
3439                        },
3440                    ],
3441                    "condition": {
3442                        "op":"eq",
3443                        "name": "event.contexts.browser.name",
3444                        "value": "Chrome"
3445                    }
3446                }
3447            ]
3448        }))
3449        .unwrap();
3450
3451        normalize_performance_score(&mut event, Some(&performance_score));
3452
3453        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3454        {
3455          "type": "transaction",
3456          "timestamp": 1619420405.0,
3457          "start_timestamp": 1619420400.0,
3458          "contexts": {
3459            "browser": {
3460              "name": "Chrome",
3461              "version": "120.1.1",
3462              "type": "browser",
3463            },
3464          },
3465          "measurements": {
3466            "a": {
3467              "value": 213.0,
3468              "unit": "millisecond",
3469            },
3470          },
3471        }
3472        "###);
3473    }
3474
3475    #[test]
3476    fn test_computed_performance_score_optional_measurement() {
3477        let json = r#"
3478        {
3479            "type": "transaction",
3480            "timestamp": "2021-04-26T08:00:05+0100",
3481            "start_timestamp": "2021-04-26T08:00:00+0100",
3482            "measurements": {
3483                "a": {"value": 213, "unit": "millisecond"},
3484                "b": {"value": 213, "unit": "millisecond"}
3485            },
3486            "contexts": {
3487                "browser": {
3488                    "name": "Chrome",
3489                    "version": "120.1.1",
3490                    "type": "browser"
3491                }
3492            }
3493        }
3494        "#;
3495
3496        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3497
3498        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3499            "profiles": [
3500                {
3501                    "name": "Desktop",
3502                    "scoreComponents": [
3503                        {
3504                            "measurement": "a",
3505                            "weight": 0.15,
3506                            "p10": 900,
3507                            "p50": 1600,
3508                        },
3509                        {
3510                            "measurement": "b",
3511                            "weight": 0.30,
3512                            "p10": 1200,
3513                            "p50": 2400,
3514                            "optional": true
3515                        },
3516                        {
3517                            "measurement": "c",
3518                            "weight": 0.55,
3519                            "p10": 1200,
3520                            "p50": 2400,
3521                            "optional": true
3522                        },
3523                    ],
3524                    "condition": {
3525                        "op":"eq",
3526                        "name": "event.contexts.browser.name",
3527                        "value": "Chrome"
3528                    }
3529                }
3530            ]
3531        }))
3532        .unwrap();
3533
3534        normalize_performance_score(&mut event, Some(&performance_score));
3535
3536        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3537        {
3538          "type": "transaction",
3539          "timestamp": 1619420405.0,
3540          "start_timestamp": 1619420400.0,
3541          "contexts": {
3542            "browser": {
3543              "name": "Chrome",
3544              "version": "120.1.1",
3545              "type": "browser",
3546            },
3547          },
3548          "measurements": {
3549            "a": {
3550              "value": 213.0,
3551              "unit": "millisecond",
3552            },
3553            "b": {
3554              "value": 213.0,
3555              "unit": "millisecond",
3556            },
3557            "score.a": {
3558              "value": 0.33333215313291975,
3559              "unit": "ratio",
3560            },
3561            "score.b": {
3562              "value": 0.66666415149198,
3563              "unit": "ratio",
3564            },
3565            "score.ratio.a": {
3566              "value": 0.9999964593987591,
3567              "unit": "ratio",
3568            },
3569            "score.ratio.b": {
3570              "value": 0.9999962272379699,
3571              "unit": "ratio",
3572            },
3573            "score.total": {
3574              "value": 0.9999963046248997,
3575              "unit": "ratio",
3576            },
3577            "score.weight.a": {
3578              "value": 0.33333333333333337,
3579              "unit": "ratio",
3580            },
3581            "score.weight.b": {
3582              "value": 0.6666666666666667,
3583              "unit": "ratio",
3584            },
3585            "score.weight.c": {
3586              "value": 0.0,
3587              "unit": "ratio",
3588            },
3589          },
3590        }
3591        "###);
3592    }
3593
3594    #[test]
3595    fn test_computed_performance_score_weight_0() {
3596        let json = r#"
3597        {
3598            "type": "transaction",
3599            "timestamp": "2021-04-26T08:00:05+0100",
3600            "start_timestamp": "2021-04-26T08:00:00+0100",
3601            "measurements": {
3602                "cls": {"value": 0.11}
3603            }
3604        }
3605        "#;
3606
3607        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3608
3609        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3610            "profiles": [
3611                {
3612                    "name": "Desktop",
3613                    "scoreComponents": [
3614                        {
3615                            "measurement": "cls",
3616                            "weight": 0,
3617                            "p10": 0.1,
3618                            "p50": 0.25
3619                        },
3620                    ],
3621                    "condition": {
3622                        "op":"and",
3623                        "inner": []
3624                    }
3625                }
3626            ]
3627        }))
3628        .unwrap();
3629
3630        normalize_performance_score(&mut event, Some(&performance_score));
3631
3632        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3633        {
3634          "type": "transaction",
3635          "timestamp": 1619420405.0,
3636          "start_timestamp": 1619420400.0,
3637          "measurements": {
3638            "cls": {
3639              "value": 0.11,
3640            },
3641          },
3642        }
3643        "###);
3644    }
3645
3646    #[test]
3647    fn test_computed_performance_score_negative_value() {
3648        let json = r#"
3649        {
3650            "type": "transaction",
3651            "timestamp": "2021-04-26T08:00:05+0100",
3652            "start_timestamp": "2021-04-26T08:00:00+0100",
3653            "measurements": {
3654                "ttfb": {"value": -100, "unit": "millisecond"}
3655            }
3656        }
3657        "#;
3658
3659        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3660
3661        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3662            "profiles": [
3663                {
3664                    "name": "Desktop",
3665                    "scoreComponents": [
3666                        {
3667                            "measurement": "ttfb",
3668                            "weight": 1.0,
3669                            "p10": 100.0,
3670                            "p50": 250.0
3671                        },
3672                    ],
3673                    "condition": {
3674                        "op":"and",
3675                        "inner": []
3676                    }
3677                }
3678            ]
3679        }))
3680        .unwrap();
3681
3682        normalize_performance_score(&mut event, Some(&performance_score));
3683
3684        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3685        {
3686          "type": "transaction",
3687          "timestamp": 1619420405.0,
3688          "start_timestamp": 1619420400.0,
3689          "measurements": {
3690            "score.ratio.ttfb": {
3691              "value": 1.0,
3692              "unit": "ratio",
3693            },
3694            "score.total": {
3695              "value": 1.0,
3696              "unit": "ratio",
3697            },
3698            "score.ttfb": {
3699              "value": 1.0,
3700              "unit": "ratio",
3701            },
3702            "score.weight.ttfb": {
3703              "value": 1.0,
3704              "unit": "ratio",
3705            },
3706            "ttfb": {
3707              "value": -100.0,
3708              "unit": "millisecond",
3709            },
3710          },
3711        }
3712        "###);
3713    }
3714
3715    #[test]
3716    fn test_filter_negative_web_vital_measurements() {
3717        let json = r#"
3718        {
3719            "type": "transaction",
3720            "timestamp": "2021-04-26T08:00:05+0100",
3721            "start_timestamp": "2021-04-26T08:00:00+0100",
3722            "measurements": {
3723                "ttfb": {"value": -100, "unit": "millisecond"}
3724            }
3725        }
3726        "#;
3727        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3728
3729        // Allow ttfb as a builtinMeasurement with allow_negative defaulted to false.
3730        let project_measurement_config: MeasurementsConfig = serde_json::from_value(json!({
3731            "builtinMeasurements": [
3732                {"name": "ttfb", "unit": "millisecond"},
3733            ],
3734        }))
3735        .unwrap();
3736
3737        let dynamic_measurement_config =
3738            CombinedMeasurementsConfig::new(Some(&project_measurement_config), None);
3739
3740        normalize_event_measurements(&mut event, Some(dynamic_measurement_config), None);
3741
3742        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3743        {
3744          "type": "transaction",
3745          "timestamp": 1619420405.0,
3746          "start_timestamp": 1619420400.0,
3747          "measurements": {},
3748          "_meta": {
3749            "measurements": {
3750              "": Meta(Some(MetaInner(
3751                err: [
3752                  [
3753                    "invalid_data",
3754                    {
3755                      "reason": "Negative value for measurement ttfb not allowed: -100",
3756                    },
3757                  ],
3758                ],
3759                val: Some({
3760                  "ttfb": {
3761                    "unit": "millisecond",
3762                    "value": -100.0,
3763                  },
3764                }),
3765              ))),
3766            },
3767          },
3768        }
3769        "###);
3770    }
3771
3772    #[test]
3773    fn test_computed_performance_score_multiple_profiles() {
3774        let json = r#"
3775        {
3776            "type": "transaction",
3777            "timestamp": "2021-04-26T08:00:05+0100",
3778            "start_timestamp": "2021-04-26T08:00:00+0100",
3779            "measurements": {
3780                "cls": {"value": 0.11},
3781                "inp": {"value": 120.0}
3782            }
3783        }
3784        "#;
3785
3786        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3787
3788        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3789            "profiles": [
3790                {
3791                    "name": "Desktop",
3792                    "scoreComponents": [
3793                        {
3794                            "measurement": "cls",
3795                            "weight": 0,
3796                            "p10": 0.1,
3797                            "p50": 0.25
3798                        },
3799                    ],
3800                    "condition": {
3801                        "op":"and",
3802                        "inner": []
3803                    }
3804                },
3805                {
3806                    "name": "Desktop",
3807                    "scoreComponents": [
3808                        {
3809                            "measurement": "inp",
3810                            "weight": 1.0,
3811                            "p10": 0.1,
3812                            "p50": 0.25
3813                        },
3814                    ],
3815                    "condition": {
3816                        "op":"and",
3817                        "inner": []
3818                    }
3819                }
3820            ]
3821        }))
3822        .unwrap();
3823
3824        normalize_performance_score(&mut event, Some(&performance_score));
3825
3826        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
3827        {
3828          "type": "transaction",
3829          "timestamp": 1619420405.0,
3830          "start_timestamp": 1619420400.0,
3831          "measurements": {
3832            "cls": {
3833              "value": 0.11,
3834            },
3835            "inp": {
3836              "value": 120.0,
3837            },
3838            "score.inp": {
3839              "value": 0.0,
3840              "unit": "ratio",
3841            },
3842            "score.ratio.inp": {
3843              "value": 0.0,
3844              "unit": "ratio",
3845            },
3846            "score.total": {
3847              "value": 0.0,
3848              "unit": "ratio",
3849            },
3850            "score.weight.inp": {
3851              "value": 1.0,
3852              "unit": "ratio",
3853            },
3854          },
3855        }
3856        "###);
3857    }
3858
3859    #[test]
3860    fn test_compute_performance_score_for_mobile_ios_profile() {
3861        let mut event = Annotated::<Event>::from_json(IOS_MOBILE_EVENT)
3862            .unwrap()
3863            .0
3864            .unwrap();
3865
3866        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3867            "profiles": [
3868                {
3869                    "name": "Mobile",
3870                    "scoreComponents": [
3871                        {
3872                            "measurement": "time_to_initial_display",
3873                            "weight": 0.25,
3874                            "p10": 1800.0,
3875                            "p50": 3000.0,
3876                            "optional": true
3877                        },
3878                        {
3879                            "measurement": "time_to_full_display",
3880                            "weight": 0.25,
3881                            "p10": 2500.0,
3882                            "p50": 4000.0,
3883                            "optional": true
3884                        },
3885                        {
3886                            "measurement": "app_start_warm",
3887                            "weight": 0.25,
3888                            "p10": 200.0,
3889                            "p50": 500.0,
3890                            "optional": true
3891                        },
3892                        {
3893                            "measurement": "app_start_cold",
3894                            "weight": 0.25,
3895                            "p10": 200.0,
3896                            "p50": 500.0,
3897                            "optional": true
3898                        }
3899                    ],
3900                    "condition": {
3901                        "op": "and",
3902                        "inner": [
3903                            {
3904                                "op": "or",
3905                                "inner": [
3906                                    {
3907                                        "op": "eq",
3908                                        "name": "event.sdk.name",
3909                                        "value": "sentry.cocoa"
3910                                    },
3911                                    {
3912                                        "op": "eq",
3913                                        "name": "event.sdk.name",
3914                                        "value": "sentry.java.android"
3915                                    }
3916                                ]
3917                            },
3918                            {
3919                                "op": "eq",
3920                                "name": "event.contexts.trace.op",
3921                                "value": "ui.load"
3922                            }
3923                        ]
3924                    }
3925                }
3926            ]
3927        }))
3928        .unwrap();
3929
3930        normalize_performance_score(&mut event, Some(&performance_score));
3931
3932        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {});
3933    }
3934
3935    #[test]
3936    fn test_compute_performance_score_for_mobile_android_profile() {
3937        let mut event = Annotated::<Event>::from_json(ANDROID_MOBILE_EVENT)
3938            .unwrap()
3939            .0
3940            .unwrap();
3941
3942        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3943            "profiles": [
3944                {
3945                    "name": "Mobile",
3946                    "scoreComponents": [
3947                        {
3948                            "measurement": "time_to_initial_display",
3949                            "weight": 0.25,
3950                            "p10": 1800.0,
3951                            "p50": 3000.0,
3952                            "optional": true
3953                        },
3954                        {
3955                            "measurement": "time_to_full_display",
3956                            "weight": 0.25,
3957                            "p10": 2500.0,
3958                            "p50": 4000.0,
3959                            "optional": true
3960                        },
3961                        {
3962                            "measurement": "app_start_warm",
3963                            "weight": 0.25,
3964                            "p10": 200.0,
3965                            "p50": 500.0,
3966                            "optional": true
3967                        },
3968                        {
3969                            "measurement": "app_start_cold",
3970                            "weight": 0.25,
3971                            "p10": 200.0,
3972                            "p50": 500.0,
3973                            "optional": true
3974                        }
3975                    ],
3976                    "condition": {
3977                        "op": "and",
3978                        "inner": [
3979                            {
3980                                "op": "or",
3981                                "inner": [
3982                                    {
3983                                        "op": "eq",
3984                                        "name": "event.sdk.name",
3985                                        "value": "sentry.cocoa"
3986                                    },
3987                                    {
3988                                        "op": "eq",
3989                                        "name": "event.sdk.name",
3990                                        "value": "sentry.java.android"
3991                                    }
3992                                ]
3993                            },
3994                            {
3995                                "op": "eq",
3996                                "name": "event.contexts.trace.op",
3997                                "value": "ui.load"
3998                            }
3999                        ]
4000                    }
4001                }
4002            ]
4003        }))
4004        .unwrap();
4005
4006        normalize_performance_score(&mut event, Some(&performance_score));
4007
4008        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {});
4009    }
4010
4011    #[test]
4012    fn test_computes_performance_score_and_tags_with_profile_version() {
4013        let json = r#"
4014        {
4015            "type": "transaction",
4016            "timestamp": "2021-04-26T08:00:05+0100",
4017            "start_timestamp": "2021-04-26T08:00:00+0100",
4018            "measurements": {
4019                "inp": {"value": 120.0}
4020            }
4021        }
4022        "#;
4023
4024        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4025
4026        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4027            "profiles": [
4028                {
4029                    "name": "Desktop",
4030                    "scoreComponents": [
4031                        {
4032                            "measurement": "inp",
4033                            "weight": 1.0,
4034                            "p10": 0.1,
4035                            "p50": 0.25
4036                        },
4037                    ],
4038                    "condition": {
4039                        "op":"and",
4040                        "inner": []
4041                    },
4042                    "version": "beta"
4043                }
4044            ]
4045        }))
4046        .unwrap();
4047
4048        normalize(
4049            &mut event,
4050            &mut Meta::default(),
4051            &NormalizationConfig {
4052                performance_score: Some(&performance_score),
4053                ..Default::default()
4054            },
4055        );
4056
4057        insta::assert_ron_snapshot!(SerializableAnnotated(&event.contexts), {}, @r###"
4058        {
4059          "performance_score": {
4060            "score_profile_version": "beta",
4061            "type": "performancescore",
4062          },
4063        }
4064        "###);
4065        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
4066        {
4067          "inp": {
4068            "value": 120.0,
4069            "unit": "millisecond",
4070          },
4071          "score.inp": {
4072            "value": 0.0,
4073            "unit": "ratio",
4074          },
4075          "score.ratio.inp": {
4076            "value": 0.0,
4077            "unit": "ratio",
4078          },
4079          "score.total": {
4080            "value": 0.0,
4081            "unit": "ratio",
4082          },
4083          "score.weight.inp": {
4084            "value": 1.0,
4085            "unit": "ratio",
4086          },
4087        }
4088        "###);
4089    }
4090
4091    #[test]
4092    fn test_computes_standalone_cls_performance_score() {
4093        let json = r#"
4094        {
4095            "type": "transaction",
4096            "timestamp": "2021-04-26T08:00:05+0100",
4097            "start_timestamp": "2021-04-26T08:00:00+0100",
4098            "measurements": {
4099                "cls": {"value": 0.5}
4100            }
4101        }
4102        "#;
4103
4104        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4105
4106        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4107            "profiles": [
4108            {
4109                "name": "Default",
4110                "scoreComponents": [
4111                    {
4112                        "measurement": "fcp",
4113                        "weight": 0.15,
4114                        "p10": 900.0,
4115                        "p50": 1600.0,
4116                        "optional": true,
4117                    },
4118                    {
4119                        "measurement": "lcp",
4120                        "weight": 0.30,
4121                        "p10": 1200.0,
4122                        "p50": 2400.0,
4123                        "optional": true,
4124                    },
4125                    {
4126                        "measurement": "cls",
4127                        "weight": 0.15,
4128                        "p10": 0.1,
4129                        "p50": 0.25,
4130                        "optional": true,
4131                    },
4132                    {
4133                        "measurement": "ttfb",
4134                        "weight": 0.10,
4135                        "p10": 200.0,
4136                        "p50": 400.0,
4137                        "optional": true,
4138                    },
4139                ],
4140                "condition": {
4141                    "op": "and",
4142                    "inner": [],
4143                },
4144            }
4145            ]
4146        }))
4147        .unwrap();
4148
4149        normalize(
4150            &mut event,
4151            &mut Meta::default(),
4152            &NormalizationConfig {
4153                performance_score: Some(&performance_score),
4154                ..Default::default()
4155            },
4156        );
4157
4158        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
4159        {
4160          "cls": {
4161            "value": 0.5,
4162            "unit": "none",
4163          },
4164          "score.cls": {
4165            "value": 0.16615877613713903,
4166            "unit": "ratio",
4167          },
4168          "score.ratio.cls": {
4169            "value": 0.16615877613713903,
4170            "unit": "ratio",
4171          },
4172          "score.total": {
4173            "value": 0.16615877613713903,
4174            "unit": "ratio",
4175          },
4176          "score.weight.cls": {
4177            "value": 1.0,
4178            "unit": "ratio",
4179          },
4180          "score.weight.fcp": {
4181            "value": 0.0,
4182            "unit": "ratio",
4183          },
4184          "score.weight.lcp": {
4185            "value": 0.0,
4186            "unit": "ratio",
4187          },
4188          "score.weight.ttfb": {
4189            "value": 0.0,
4190            "unit": "ratio",
4191          },
4192        }
4193        "###);
4194    }
4195
4196    #[test]
4197    fn test_computes_standalone_lcp_performance_score() {
4198        let json = r#"
4199        {
4200            "type": "transaction",
4201            "timestamp": "2021-04-26T08:00:05+0100",
4202            "start_timestamp": "2021-04-26T08:00:00+0100",
4203            "measurements": {
4204                "lcp": {"value": 1200.0}
4205            }
4206        }
4207        "#;
4208
4209        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4210
4211        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4212            "profiles": [
4213            {
4214                "name": "Default",
4215                "scoreComponents": [
4216                    {
4217                        "measurement": "fcp",
4218                        "weight": 0.15,
4219                        "p10": 900.0,
4220                        "p50": 1600.0,
4221                        "optional": true,
4222                    },
4223                    {
4224                        "measurement": "lcp",
4225                        "weight": 0.30,
4226                        "p10": 1200.0,
4227                        "p50": 2400.0,
4228                        "optional": true,
4229                    },
4230                    {
4231                        "measurement": "cls",
4232                        "weight": 0.15,
4233                        "p10": 0.1,
4234                        "p50": 0.25,
4235                        "optional": true,
4236                    },
4237                    {
4238                        "measurement": "ttfb",
4239                        "weight": 0.10,
4240                        "p10": 200.0,
4241                        "p50": 400.0,
4242                        "optional": true,
4243                    },
4244                ],
4245                "condition": {
4246                    "op": "and",
4247                    "inner": [],
4248                },
4249            }
4250            ]
4251        }))
4252        .unwrap();
4253
4254        normalize(
4255            &mut event,
4256            &mut Meta::default(),
4257            &NormalizationConfig {
4258                performance_score: Some(&performance_score),
4259                ..Default::default()
4260            },
4261        );
4262
4263        insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
4264        {
4265          "lcp": {
4266            "value": 1200.0,
4267            "unit": "millisecond",
4268          },
4269          "score.lcp": {
4270            "value": 0.8999999314038525,
4271            "unit": "ratio",
4272          },
4273          "score.ratio.lcp": {
4274            "value": 0.8999999314038525,
4275            "unit": "ratio",
4276          },
4277          "score.total": {
4278            "value": 0.8999999314038525,
4279            "unit": "ratio",
4280          },
4281          "score.weight.cls": {
4282            "value": 0.0,
4283            "unit": "ratio",
4284          },
4285          "score.weight.fcp": {
4286            "value": 0.0,
4287            "unit": "ratio",
4288          },
4289          "score.weight.lcp": {
4290            "value": 1.0,
4291            "unit": "ratio",
4292          },
4293          "score.weight.ttfb": {
4294            "value": 0.0,
4295            "unit": "ratio",
4296          },
4297        }
4298        "###);
4299    }
4300
4301    #[test]
4302    fn test_computed_performance_score_uses_first_matching_profile() {
4303        let json = r#"
4304        {
4305            "type": "transaction",
4306            "timestamp": "2021-04-26T08:00:05+0100",
4307            "start_timestamp": "2021-04-26T08:00:00+0100",
4308            "measurements": {
4309                "a": {"value": 213, "unit": "millisecond"},
4310                "b": {"value": 213, "unit": "millisecond"}
4311            },
4312            "contexts": {
4313                "browser": {
4314                    "name": "Chrome",
4315                    "version": "120.1.1",
4316                    "type": "browser"
4317                }
4318            }
4319        }
4320        "#;
4321
4322        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4323
4324        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4325            "profiles": [
4326                {
4327                    "name": "Mobile",
4328                    "scoreComponents": [
4329                        {
4330                            "measurement": "a",
4331                            "weight": 0.15,
4332                            "p10": 100,
4333                            "p50": 200,
4334                        },
4335                        {
4336                            "measurement": "b",
4337                            "weight": 0.30,
4338                            "p10": 100,
4339                            "p50": 200,
4340                            "optional": true
4341                        },
4342                        {
4343                            "measurement": "c",
4344                            "weight": 0.55,
4345                            "p10": 100,
4346                            "p50": 200,
4347                            "optional": true
4348                        },
4349                    ],
4350                    "condition": {
4351                        "op":"eq",
4352                        "name": "event.contexts.browser.name",
4353                        "value": "Chrome Mobile"
4354                    }
4355                },
4356                {
4357                    "name": "Desktop",
4358                    "scoreComponents": [
4359                        {
4360                            "measurement": "a",
4361                            "weight": 0.15,
4362                            "p10": 900,
4363                            "p50": 1600,
4364                        },
4365                        {
4366                            "measurement": "b",
4367                            "weight": 0.30,
4368                            "p10": 1200,
4369                            "p50": 2400,
4370                            "optional": true
4371                        },
4372                        {
4373                            "measurement": "c",
4374                            "weight": 0.55,
4375                            "p10": 1200,
4376                            "p50": 2400,
4377                            "optional": true
4378                        },
4379                    ],
4380                    "condition": {
4381                        "op":"eq",
4382                        "name": "event.contexts.browser.name",
4383                        "value": "Chrome"
4384                    }
4385                },
4386                {
4387                    "name": "Default",
4388                    "scoreComponents": [
4389                        {
4390                            "measurement": "a",
4391                            "weight": 0.15,
4392                            "p10": 100,
4393                            "p50": 200,
4394                        },
4395                        {
4396                            "measurement": "b",
4397                            "weight": 0.30,
4398                            "p10": 100,
4399                            "p50": 200,
4400                            "optional": true
4401                        },
4402                        {
4403                            "measurement": "c",
4404                            "weight": 0.55,
4405                            "p10": 100,
4406                            "p50": 200,
4407                            "optional": true
4408                        },
4409                    ],
4410                    "condition": {
4411                        "op": "and",
4412                        "inner": [],
4413                    }
4414                }
4415            ]
4416        }))
4417        .unwrap();
4418
4419        normalize_performance_score(&mut event, Some(&performance_score));
4420
4421        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4422        {
4423          "type": "transaction",
4424          "timestamp": 1619420405.0,
4425          "start_timestamp": 1619420400.0,
4426          "contexts": {
4427            "browser": {
4428              "name": "Chrome",
4429              "version": "120.1.1",
4430              "type": "browser",
4431            },
4432          },
4433          "measurements": {
4434            "a": {
4435              "value": 213.0,
4436              "unit": "millisecond",
4437            },
4438            "b": {
4439              "value": 213.0,
4440              "unit": "millisecond",
4441            },
4442            "score.a": {
4443              "value": 0.33333215313291975,
4444              "unit": "ratio",
4445            },
4446            "score.b": {
4447              "value": 0.66666415149198,
4448              "unit": "ratio",
4449            },
4450            "score.ratio.a": {
4451              "value": 0.9999964593987591,
4452              "unit": "ratio",
4453            },
4454            "score.ratio.b": {
4455              "value": 0.9999962272379699,
4456              "unit": "ratio",
4457            },
4458            "score.total": {
4459              "value": 0.9999963046248997,
4460              "unit": "ratio",
4461            },
4462            "score.weight.a": {
4463              "value": 0.33333333333333337,
4464              "unit": "ratio",
4465            },
4466            "score.weight.b": {
4467              "value": 0.6666666666666667,
4468              "unit": "ratio",
4469            },
4470            "score.weight.c": {
4471              "value": 0.0,
4472              "unit": "ratio",
4473            },
4474          },
4475        }
4476        "###);
4477    }
4478
4479    #[test]
4480    fn test_computed_performance_score_falls_back_to_default_profile() {
4481        let json = r#"
4482        {
4483            "type": "transaction",
4484            "timestamp": "2021-04-26T08:00:05+0100",
4485            "start_timestamp": "2021-04-26T08:00:00+0100",
4486            "measurements": {
4487                "a": {"value": 213, "unit": "millisecond"},
4488                "b": {"value": 213, "unit": "millisecond"}
4489            },
4490            "contexts": {}
4491        }
4492        "#;
4493
4494        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4495
4496        let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
4497            "profiles": [
4498                {
4499                    "name": "Mobile",
4500                    "scoreComponents": [
4501                        {
4502                            "measurement": "a",
4503                            "weight": 0.15,
4504                            "p10": 900,
4505                            "p50": 1600,
4506                            "optional": true
4507                        },
4508                        {
4509                            "measurement": "b",
4510                            "weight": 0.30,
4511                            "p10": 1200,
4512                            "p50": 2400,
4513                            "optional": true
4514                        },
4515                        {
4516                            "measurement": "c",
4517                            "weight": 0.55,
4518                            "p10": 1200,
4519                            "p50": 2400,
4520                            "optional": true
4521                        },
4522                    ],
4523                    "condition": {
4524                        "op":"eq",
4525                        "name": "event.contexts.browser.name",
4526                        "value": "Chrome Mobile"
4527                    }
4528                },
4529                {
4530                    "name": "Desktop",
4531                    "scoreComponents": [
4532                        {
4533                            "measurement": "a",
4534                            "weight": 0.15,
4535                            "p10": 900,
4536                            "p50": 1600,
4537                            "optional": true
4538                        },
4539                        {
4540                            "measurement": "b",
4541                            "weight": 0.30,
4542                            "p10": 1200,
4543                            "p50": 2400,
4544                            "optional": true
4545                        },
4546                        {
4547                            "measurement": "c",
4548                            "weight": 0.55,
4549                            "p10": 1200,
4550                            "p50": 2400,
4551                            "optional": true
4552                        },
4553                    ],
4554                    "condition": {
4555                        "op":"eq",
4556                        "name": "event.contexts.browser.name",
4557                        "value": "Chrome"
4558                    }
4559                },
4560                {
4561                    "name": "Default",
4562                    "scoreComponents": [
4563                        {
4564                            "measurement": "a",
4565                            "weight": 0.15,
4566                            "p10": 100,
4567                            "p50": 200,
4568                            "optional": true
4569                        },
4570                        {
4571                            "measurement": "b",
4572                            "weight": 0.30,
4573                            "p10": 100,
4574                            "p50": 200,
4575                            "optional": true
4576                        },
4577                        {
4578                            "measurement": "c",
4579                            "weight": 0.55,
4580                            "p10": 100,
4581                            "p50": 200,
4582                            "optional": true
4583                        },
4584                    ],
4585                    "condition": {
4586                        "op": "and",
4587                        "inner": [],
4588                    }
4589                }
4590            ]
4591        }))
4592        .unwrap();
4593
4594        normalize_performance_score(&mut event, Some(&performance_score));
4595
4596        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4597        {
4598          "type": "transaction",
4599          "timestamp": 1619420405.0,
4600          "start_timestamp": 1619420400.0,
4601          "contexts": {},
4602          "measurements": {
4603            "a": {
4604              "value": 213.0,
4605              "unit": "millisecond",
4606            },
4607            "b": {
4608              "value": 213.0,
4609              "unit": "millisecond",
4610            },
4611            "score.a": {
4612              "value": 0.15121816827413334,
4613              "unit": "ratio",
4614            },
4615            "score.b": {
4616              "value": 0.3024363365482667,
4617              "unit": "ratio",
4618            },
4619            "score.ratio.a": {
4620              "value": 0.45365450482239994,
4621              "unit": "ratio",
4622            },
4623            "score.ratio.b": {
4624              "value": 0.45365450482239994,
4625              "unit": "ratio",
4626            },
4627            "score.total": {
4628              "value": 0.4536545048224,
4629              "unit": "ratio",
4630            },
4631            "score.weight.a": {
4632              "value": 0.33333333333333337,
4633              "unit": "ratio",
4634            },
4635            "score.weight.b": {
4636              "value": 0.6666666666666667,
4637              "unit": "ratio",
4638            },
4639            "score.weight.c": {
4640              "value": 0.0,
4641              "unit": "ratio",
4642            },
4643          },
4644        }
4645        "###);
4646    }
4647
4648    #[test]
4649    fn test_normalization_removes_reprocessing_context() {
4650        let json = r#"{
4651            "contexts": {
4652                "reprocessing": {}
4653            }
4654        }"#;
4655        let mut event = Annotated::<Event>::from_json(json).unwrap();
4656        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
4657        normalize_event(&mut event, &NormalizationConfig::default());
4658        assert!(!get_value!(event.contexts!).contains_key("reprocessing"));
4659    }
4660
4661    #[test]
4662    fn test_renormalization_does_not_remove_reprocessing_context() {
4663        let json = r#"{
4664            "contexts": {
4665                "reprocessing": {}
4666            }
4667        }"#;
4668        let mut event = Annotated::<Event>::from_json(json).unwrap();
4669        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
4670        normalize_event(
4671            &mut event,
4672            &NormalizationConfig {
4673                is_renormalize: true,
4674                ..Default::default()
4675            },
4676        );
4677        assert!(get_value!(event.contexts!).contains_key("reprocessing"));
4678    }
4679
4680    #[test]
4681    fn test_normalize_user() {
4682        let json = r#"{
4683            "user": {
4684                "id": "123456",
4685                "username": "john",
4686                "other": "value"
4687            }
4688        }"#;
4689        let mut event = Annotated::<Event>::from_json(json).unwrap();
4690        normalize_user(event.value_mut().as_mut().unwrap());
4691
4692        let user = event.value().unwrap().user.value().unwrap();
4693        assert_eq!(user.data, {
4694            let mut map = Object::new();
4695            map.insert(
4696                "other".to_owned(),
4697                Annotated::new(Value::String("value".to_owned())),
4698            );
4699            Annotated::new(map)
4700        });
4701        assert_eq!(user.other, Object::new());
4702        assert_eq!(user.username, Annotated::new("john".to_owned().into()));
4703        assert_eq!(user.sentry_user, Annotated::new("id:123456".to_owned()));
4704    }
4705
4706    #[test]
4707    fn test_handle_types_in_spaced_exception_values() {
4708        let mut exception = Annotated::new(Exception {
4709            value: Annotated::new("ValueError: unauthorized".to_owned().into()),
4710            ..Exception::default()
4711        });
4712        normalize_exception(&mut exception);
4713
4714        let exception = exception.value().unwrap();
4715        assert_eq!(exception.value.as_str(), Some("unauthorized"));
4716        assert_eq!(exception.ty.as_str(), Some("ValueError"));
4717    }
4718
4719    #[test]
4720    fn test_handle_types_in_non_spaced_excepton_values() {
4721        let mut exception = Annotated::new(Exception {
4722            value: Annotated::new("ValueError:unauthorized".to_owned().into()),
4723            ..Exception::default()
4724        });
4725        normalize_exception(&mut exception);
4726
4727        let exception = exception.value().unwrap();
4728        assert_eq!(exception.value.as_str(), Some("unauthorized"));
4729        assert_eq!(exception.ty.as_str(), Some("ValueError"));
4730    }
4731
4732    #[test]
4733    fn test_rejects_empty_exception_fields() {
4734        let mut exception = Annotated::new(Exception {
4735            value: Annotated::new("".to_owned().into()),
4736            ty: Annotated::new("".to_owned()),
4737            ..Default::default()
4738        });
4739
4740        normalize_exception(&mut exception);
4741
4742        assert!(exception.value().is_none());
4743        assert!(exception.meta().has_errors());
4744    }
4745
4746    #[test]
4747    fn test_json_value() {
4748        let mut exception = Annotated::new(Exception {
4749            value: Annotated::new(r#"{"unauthorized":true}"#.to_owned().into()),
4750            ..Exception::default()
4751        });
4752
4753        normalize_exception(&mut exception);
4754
4755        let exception = exception.value().unwrap();
4756
4757        // Don't split a json-serialized value on the colon
4758        assert_eq!(exception.value.as_str(), Some(r#"{"unauthorized":true}"#));
4759        assert_eq!(exception.ty.value(), None);
4760    }
4761
4762    #[test]
4763    fn test_exception_invalid() {
4764        let mut exception = Annotated::new(Exception::default());
4765
4766        normalize_exception(&mut exception);
4767
4768        let expected = Error::with(ErrorKind::MissingAttribute, |error| {
4769            error.insert("attribute", "type or value");
4770        });
4771        assert_eq!(
4772            exception.meta().iter_errors().collect_tuple(),
4773            Some((&expected,))
4774        );
4775    }
4776
4777    #[test]
4778    fn test_normalize_exception() {
4779        let mut event = Annotated::new(Event {
4780            exceptions: Annotated::new(Values::new(vec![Annotated::new(Exception {
4781                // Exception with missing type and value
4782                ty: Annotated::empty(),
4783                value: Annotated::empty(),
4784                ..Default::default()
4785            })])),
4786            ..Default::default()
4787        });
4788
4789        normalize_event(&mut event, &NormalizationConfig::default());
4790
4791        let exception = event
4792            .value()
4793            .unwrap()
4794            .exceptions
4795            .value()
4796            .unwrap()
4797            .values
4798            .value()
4799            .unwrap()
4800            .first()
4801            .unwrap();
4802
4803        assert_debug_snapshot!(exception.meta(), @r###"
4804        Meta {
4805            remarks: [],
4806            errors: [
4807                Error {
4808                    kind: MissingAttribute,
4809                    data: {
4810                        "attribute": String(
4811                            "type or value",
4812                        ),
4813                    },
4814                },
4815            ],
4816            original_length: None,
4817            original_value: Some(
4818                Object(
4819                    {
4820                        "mechanism": ~,
4821                        "module": ~,
4822                        "raw_stacktrace": ~,
4823                        "stacktrace": ~,
4824                        "thread_id": ~,
4825                        "type": ~,
4826                        "value": ~,
4827                    },
4828                ),
4829            ),
4830        }
4831        "###);
4832    }
4833
4834    #[test]
4835    fn test_normalize_breadcrumbs() {
4836        let mut event = Event {
4837            breadcrumbs: Annotated::new(Values {
4838                values: Annotated::new(vec![Annotated::new(Breadcrumb::default())]),
4839                ..Default::default()
4840            }),
4841            ..Default::default()
4842        };
4843        normalize_breadcrumbs(&mut event);
4844
4845        let breadcrumb = event
4846            .breadcrumbs
4847            .value()
4848            .unwrap()
4849            .values
4850            .value()
4851            .unwrap()
4852            .first()
4853            .unwrap()
4854            .value()
4855            .unwrap();
4856        assert_eq!(breadcrumb.ty.value().unwrap(), "default");
4857        assert_eq!(&breadcrumb.level.value().unwrap().to_string(), "info");
4858    }
4859
4860    #[test]
4861    fn test_other_debug_images_have_meta_errors() {
4862        let mut event = Event {
4863            debug_meta: Annotated::new(DebugMeta {
4864                images: Annotated::new(vec![Annotated::new(
4865                    DebugImage::Other(BTreeMap::default()),
4866                )]),
4867                ..Default::default()
4868            }),
4869            ..Default::default()
4870        };
4871        normalize_debug_meta(&mut event);
4872
4873        let debug_image_meta = event
4874            .debug_meta
4875            .value()
4876            .unwrap()
4877            .images
4878            .value()
4879            .unwrap()
4880            .first()
4881            .unwrap()
4882            .meta();
4883        assert_debug_snapshot!(debug_image_meta, @r###"
4884        Meta {
4885            remarks: [],
4886            errors: [
4887                Error {
4888                    kind: InvalidData,
4889                    data: {
4890                        "reason": String(
4891                            "unsupported debug image type",
4892                        ),
4893                    },
4894                },
4895            ],
4896            original_length: None,
4897            original_value: Some(
4898                Object(
4899                    {},
4900                ),
4901            ),
4902        }
4903        "###);
4904    }
4905
4906    #[test]
4907    fn test_skip_span_normalization_when_configured() {
4908        let json = r#"{
4909            "type": "transaction",
4910            "start_timestamp": 1,
4911            "timestamp": 2,
4912            "contexts": {
4913                "trace": {
4914                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
4915                    "span_id": "aaaaaaaaaaaaaaaa"
4916                }
4917            },
4918            "spans": [
4919                {
4920                    "op": "db",
4921                    "description": "SELECT * FROM table;",
4922                    "start_timestamp": 1,
4923                    "timestamp": 2,
4924                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
4925                    "span_id": "bbbbbbbbbbbbbbbb",
4926                    "parent_span_id": "aaaaaaaaaaaaaaaa"
4927                }
4928            ]
4929        }"#;
4930
4931        let mut event = Annotated::<Event>::from_json(json).unwrap();
4932        assert!(get_value!(event.spans[0].exclusive_time).is_none());
4933        normalize_event(
4934            &mut event,
4935            &NormalizationConfig {
4936                is_renormalize: true,
4937                ..Default::default()
4938            },
4939        );
4940        assert!(get_value!(event.spans[0].exclusive_time).is_none());
4941        normalize_event(
4942            &mut event,
4943            &NormalizationConfig {
4944                is_renormalize: false,
4945                ..Default::default()
4946            },
4947        );
4948        assert!(get_value!(event.spans[0].exclusive_time).is_some());
4949    }
4950
4951    #[test]
4952    fn test_normalize_trace_context_tags_extracts_lcp_info() {
4953        let json = r#"{
4954            "type": "transaction",
4955            "start_timestamp": 1,
4956            "timestamp": 2,
4957            "contexts": {
4958                "trace": {
4959                    "data": {
4960                        "lcp.element": "body > div#app > div > h1#header",
4961                        "lcp.size": 24827,
4962                        "lcp.id": "header",
4963                        "lcp.url": "http://example.com/image.jpg"
4964                    }
4965                }
4966            },
4967            "measurements": {
4968                "lcp": { "value": 146.20000000298023, "unit": "millisecond" }
4969            }
4970        }"#;
4971        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
4972        normalize_trace_context_tags(&mut event);
4973        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
4974        {
4975          "type": "transaction",
4976          "timestamp": 2.0,
4977          "start_timestamp": 1.0,
4978          "contexts": {
4979            "trace": {
4980              "data": {
4981                "lcp.element": "body > div#app > div > h1#header",
4982                "lcp.size": 24827,
4983                "lcp.id": "header",
4984                "lcp.url": "http://example.com/image.jpg",
4985              },
4986              "type": "trace",
4987            },
4988          },
4989          "tags": [
4990            [
4991              "lcp.element",
4992              "body > div#app > div > h1#header",
4993            ],
4994            [
4995              "lcp.size",
4996              "24827",
4997            ],
4998            [
4999              "lcp.id",
5000              "header",
5001            ],
5002            [
5003              "lcp.url",
5004              "http://example.com/image.jpg",
5005            ],
5006          ],
5007          "measurements": {
5008            "lcp": {
5009              "value": 146.20000000298023,
5010              "unit": "millisecond",
5011            },
5012          },
5013        }
5014        "###);
5015    }
5016
5017    #[test]
5018    fn test_normalize_trace_context_tags_does_not_overwrite_lcp_tags() {
5019        let json = r#"{
5020          "type": "transaction",
5021          "start_timestamp": 1,
5022          "timestamp": 2,
5023          "contexts": {
5024              "trace": {
5025                  "data": {
5026                      "lcp.element": "body > div#app > div > h1#id",
5027                      "lcp.size": 33333,
5028                      "lcp.id": "id",
5029                      "lcp.url": "http://example.com/another-image.jpg"
5030                  }
5031              }
5032          },
5033          "tags": {
5034              "lcp.element": "body > div#app > div > h1#header",
5035              "lcp.size": 24827,
5036              "lcp.id": "header",
5037              "lcp.url": "http://example.com/image.jpg"
5038          },
5039          "measurements": {
5040              "lcp": { "value": 146.20000000298023, "unit": "millisecond" }
5041          }
5042        }"#;
5043        let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
5044        normalize_trace_context_tags(&mut event);
5045        insta::assert_ron_snapshot!(SerializableAnnotated(&Annotated::new(event)), {}, @r###"
5046        {
5047          "type": "transaction",
5048          "timestamp": 2.0,
5049          "start_timestamp": 1.0,
5050          "contexts": {
5051            "trace": {
5052              "data": {
5053                "lcp.element": "body > div#app > div > h1#id",
5054                "lcp.size": 33333,
5055                "lcp.id": "id",
5056                "lcp.url": "http://example.com/another-image.jpg",
5057              },
5058              "type": "trace",
5059            },
5060          },
5061          "tags": [
5062            [
5063              "lcp.element",
5064              "body > div#app > div > h1#header",
5065            ],
5066            [
5067              "lcp.id",
5068              "header",
5069            ],
5070            [
5071              "lcp.size",
5072              "24827",
5073            ],
5074            [
5075              "lcp.url",
5076              "http://example.com/image.jpg",
5077            ],
5078          ],
5079          "measurements": {
5080            "lcp": {
5081              "value": 146.20000000298023,
5082              "unit": "millisecond",
5083            },
5084          },
5085        }
5086        "###);
5087    }
5088
5089    #[test]
5090    fn test_tags_are_trimmed() {
5091        let json = r#"
5092            {
5093                "tags": {
5094                    "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_",
5095                    "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"
5096                }
5097            }
5098        "#;
5099
5100        let mut event = Annotated::<Event>::from_json(json).unwrap();
5101
5102        normalize_event(
5103            &mut event,
5104            &NormalizationConfig {
5105                enable_trimming: true,
5106                ..NormalizationConfig::default()
5107            },
5108        );
5109
5110        insta::assert_debug_snapshot!(get_value!(event.tags!), @r###"
5111        Tags(
5112            PairList(
5113                [
5114                    TagEntry(
5115                        "key",
5116                        Annotated(
5117                            "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...",
5118                            Meta {
5119                                remarks: [
5120                                    Remark {
5121                                        ty: Substituted,
5122                                        rule_id: "!limit",
5123                                        range: Some(
5124                                            (
5125                                                197,
5126                                                200,
5127                                            ),
5128                                        ),
5129                                    },
5130                                ],
5131                                errors: [],
5132                                original_length: Some(
5133                                    210,
5134                                ),
5135                                original_value: None,
5136                            },
5137                        ),
5138                    ),
5139                    TagEntry(
5140                        Annotated(
5141                            "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...",
5142                            Meta {
5143                                remarks: [
5144                                    Remark {
5145                                        ty: Substituted,
5146                                        rule_id: "!limit",
5147                                        range: Some(
5148                                            (
5149                                                197,
5150                                                200,
5151                                            ),
5152                                        ),
5153                                    },
5154                                ],
5155                                errors: [],
5156                                original_length: Some(
5157                                    210,
5158                                ),
5159                                original_value: None,
5160                            },
5161                        ),
5162                        "value",
5163                    ),
5164                ],
5165            ),
5166        )
5167        "###);
5168    }
5169}