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