Skip to main content

relay_event_normalization/eap/
mobile.rs

1//! Mobile-specific normalizations for SpanV2 attributes.
2
3use std::time::Duration;
4
5use relay_conventions::attributes::*;
6use relay_event_schema::protocol::{Attributes, DeviceClass};
7use relay_protocol::Annotated;
8
9use crate::normalize::utils::{MAIN_THREAD_NAME, MAX_DURATION_MOBILE_MS, MOBILE_SDKS};
10
11/// Normalizes mobile-specific attributes on a span.
12///
13/// - Sets `sentry.mobile: "true"` if the SDK is a known mobile SDK.
14/// - Sets `sentry.main_thread: "true"` if the SDK is mobile and `thread.name` is `"main"`.
15/// - Removes mobile measurement attributes that exceed 180 seconds.
16/// - Normalizes V1 `app_start_cold`/`app_start_warm` into unified `app.vitals.start.*` attributes.
17/// - Derives `device.class` from device attributes if not already set.
18pub fn normalize_mobile_attributes(attributes: &mut Annotated<Attributes>) {
19    let Some(attrs) = attributes.value_mut() else {
20        return;
21    };
22
23    if let Some(sdk_name) = attrs.get_value(SENTRY__SDK__NAME).and_then(|v| v.as_str())
24        && MOBILE_SDKS.contains(&sdk_name)
25    {
26        attrs.insert(SENTRY__MOBILE, "true".to_owned());
27
28        if let Some(thread_name) = attrs.get_value(THREAD__NAME).and_then(|v| v.as_str())
29            && thread_name == MAIN_THREAD_NAME
30        {
31            attrs.insert(SENTRY__MAIN_THREAD, "true".to_owned());
32        }
33    }
34
35    for key in [
36        APP__VITALS__START__COLD__VALUE,
37        APP__VITALS__START__WARM__VALUE,
38        APP__VITALS__START__VALUE,
39        APP__VITALS__TTID__VALUE,
40        APP__VITALS__TTFD__VALUE,
41    ] {
42        if let Some(value) = attrs.get_value(key).and_then(|v| v.as_f64())
43            && value > MAX_DURATION_MOBILE_MS
44        {
45            attrs.remove(key);
46        }
47    }
48
49    // Normalize app start measurements into unified attributes.
50    // V1 spans have measurements `app_start_cold`/`app_start_warm` which become
51    // attributes `app.vitals.start.cold.value` and `app.vitals.start.warm.value`, respectively,
52    // after v1→v2 conversion. V2 spans will at some point send `app.vitals.start.value` + `app.vitals.start.type` directly.
53    if !attrs.contains_key(APP__VITALS__START__VALUE) {
54        if let Some(value) = attrs
55            .get_value(APP__VITALS__START__COLD__VALUE)
56            .and_then(|v| v.as_f64())
57            && value <= MAX_DURATION_MOBILE_MS
58        {
59            attrs.insert(APP__VITALS__START__VALUE, value);
60            attrs.insert_if_missing(APP__VITALS__START__TYPE, || "cold".to_owned());
61        } else if let Some(value) = attrs
62            .get_value(APP__VITALS__START__WARM__VALUE)
63            .and_then(|v| v.as_f64())
64            && value <= MAX_DURATION_MOBILE_MS
65        {
66            attrs.insert(APP__VITALS__START__VALUE, value);
67            attrs.insert_if_missing(APP__VITALS__START__TYPE, || "warm".to_owned());
68        }
69    }
70
71    // Derive device.class from device attributes if not already set.
72    if !attrs.contains_key(DEVICE__CLASS)
73        && let Some(device_class) = DeviceClass::from_attributes(attrs)
74    {
75        attrs.insert(DEVICE__CLASS, device_class.to_string());
76    }
77}
78
79/// Compute additional measurements for mobile spans.
80///
81/// The added measurements are:
82///
83/// * [`FRAMES_SLOW_RATE`] := [`APP__VITALS__FRAMES__SLOW__COUNT`] / [`APP__VITALS__FRAMES__TOTAL__COUNT`]
84/// * [`FRAMES_FROZEN_RATE`] := [`APP__VITALS__FRAMES__FROZEN__COUNT`] / [`APP__VITALS__FRAMES__TOTAL__COUNT`]
85/// * [`STALL_PERCENTAGE`] := [`STALL_TOTAL_TIME`] / `span_duration`
86pub fn normalize_mobile_measurements(
87    attributes: &mut Annotated<Attributes>,
88    span_duration: Option<Duration>,
89) {
90    let Some(attributes) = attributes.value_mut() else {
91        return;
92    };
93
94    if let Some(frames_total) = attributes
95        .get_value(APP__VITALS__FRAMES__TOTAL__COUNT)
96        .and_then(|v| v.as_f64())
97        && frames_total > 0.0
98    {
99        if let Some(frames_frozen) = attributes
100            .get_value(APP__VITALS__FRAMES__FROZEN__COUNT)
101            .and_then(|v| v.as_f64())
102        {
103            let frames_frozen_rate = frames_frozen / frames_total;
104            attributes.insert(
105                APP__VITALS__FRAMES__FROZEN__RATE.to_owned(),
106                frames_frozen_rate,
107            );
108        }
109
110        if let Some(frames_slow) = attributes
111            .get_value(APP__VITALS__FRAMES__SLOW__COUNT)
112            .and_then(|v| v.as_f64())
113        {
114            let frames_slow_rate = frames_slow / frames_total;
115            attributes.insert(APP__VITALS__FRAMES__SLOW__RATE.to_owned(), frames_slow_rate);
116        }
117    }
118
119    // Get stall_percentage
120    if let Some(span_duration) = span_duration
121        && !span_duration.is_zero()
122        && let Some(stall_total_time_ms) = attributes
123            .get_value(APP__VITALS__STALL__DURATION)
124            .and_then(|v| v.as_f64())
125    {
126        let stall_percentage = stall_total_time_ms / (span_duration.as_millis() as f64);
127        attributes.insert(APP__VITALS__STALL__PERCENTAGE.to_owned(), stall_percentage);
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use relay_protocol::assert_annotated_snapshot;
134
135    use super::*;
136
137    macro_rules! attributes {
138        ($($key:expr => $value:expr),* $(,)?) => {
139            Attributes::from([
140                $(($key.into(), Annotated::new($value.into())),)*
141            ])
142        };
143    }
144
145    macro_rules! mobile_sdk_test {
146        ($name:ident, $sdk:expr) => {
147            #[test]
148            fn $name() {
149                let mut attributes = Annotated::new(attributes! {
150                    SENTRY__SDK__NAME => $sdk,
151                });
152                normalize_mobile_attributes(&mut attributes);
153                assert_annotated_snapshot!(attributes);
154            }
155        };
156    }
157
158    mobile_sdk_test!(test_mobile_tag_cocoa, "sentry.cocoa");
159    mobile_sdk_test!(test_mobile_tag_flutter, "sentry.dart.flutter");
160    mobile_sdk_test!(test_mobile_tag_android, "sentry.java.android");
161    mobile_sdk_test!(
162        test_mobile_tag_react_native,
163        "sentry.javascript.react-native"
164    );
165
166    #[test]
167    fn test_mobile_tag_not_mobile_sdk() {
168        let mut attributes = Annotated::new(attributes! {
169            SENTRY__SDK__NAME => "sentry.python",
170        });
171
172        normalize_mobile_attributes(&mut attributes);
173
174        assert_annotated_snapshot!(attributes, @r#"
175        {
176          "sentry.sdk.name": {
177            "type": "string",
178            "value": "sentry.python"
179          }
180        }
181        "#);
182    }
183
184    #[test]
185    fn test_main_thread_tag_mobile_sdk() {
186        let mut attributes = Annotated::new(attributes! {
187            SENTRY__SDK__NAME => "sentry.cocoa",
188            THREAD__NAME => "main",
189        });
190
191        normalize_mobile_attributes(&mut attributes);
192
193        assert_annotated_snapshot!(attributes, @r#"
194        {
195          "sentry.main_thread": {
196            "type": "string",
197            "value": "true"
198          },
199          "sentry.mobile": {
200            "type": "string",
201            "value": "true"
202          },
203          "sentry.sdk.name": {
204            "type": "string",
205            "value": "sentry.cocoa"
206          },
207          "thread.name": {
208            "type": "string",
209            "value": "main"
210          }
211        }
212        "#);
213    }
214
215    #[test]
216    fn test_main_thread_tag_not_main() {
217        let mut attributes = Annotated::new(attributes! {
218            SENTRY__SDK__NAME => "sentry.cocoa",
219            THREAD__NAME => "background",
220        });
221
222        normalize_mobile_attributes(&mut attributes);
223
224        assert_annotated_snapshot!(attributes, @r#"
225        {
226          "sentry.mobile": {
227            "type": "string",
228            "value": "true"
229          },
230          "sentry.sdk.name": {
231            "type": "string",
232            "value": "sentry.cocoa"
233          },
234          "thread.name": {
235            "type": "string",
236            "value": "background"
237          }
238        }
239        "#);
240    }
241
242    #[test]
243    fn test_main_thread_tag_not_set_for_non_mobile_sdk() {
244        let mut attributes = Annotated::new(attributes! {
245            SENTRY__SDK__NAME => "sentry.python",
246            THREAD__NAME => "main",
247        });
248
249        normalize_mobile_attributes(&mut attributes);
250
251        assert_annotated_snapshot!(attributes, @r#"
252        {
253          "sentry.sdk.name": {
254            "type": "string",
255            "value": "sentry.python"
256          },
257          "thread.name": {
258            "type": "string",
259            "value": "main"
260          }
261        }
262        "#);
263    }
264
265    macro_rules! outlier_test {
266        ($name:ident, $key:expr, $value:expr) => {
267            #[test]
268            fn $name() {
269                let mut attributes = Annotated::new(attributes! {
270                    $key => $value,
271                });
272                normalize_mobile_attributes(&mut attributes);
273                assert_annotated_snapshot!(attributes);
274            }
275        };
276    }
277
278    outlier_test!(
279        test_outlier_removes_start_cold,
280        APP__VITALS__START__COLD__VALUE,
281        200_000.0
282    );
283    outlier_test!(
284        test_outlier_removes_start_warm,
285        APP__VITALS__START__WARM__VALUE,
286        200_000.0
287    );
288    outlier_test!(
289        test_outlier_removes_start_value,
290        APP__VITALS__START__VALUE,
291        200_000.0
292    );
293    outlier_test!(
294        test_outlier_removes_ttid,
295        APP__VITALS__TTID__VALUE,
296        200_000.0
297    );
298    outlier_test!(
299        test_outlier_removes_ttfd,
300        APP__VITALS__TTFD__VALUE,
301        200_000.0
302    );
303
304    outlier_test!(
305        test_outlier_keeps_start_cold,
306        APP__VITALS__START__COLD__VALUE,
307        5000.0
308    );
309    outlier_test!(
310        test_outlier_keeps_start_warm,
311        APP__VITALS__START__WARM__VALUE,
312        5000.0
313    );
314    outlier_test!(
315        test_outlier_keeps_start_value,
316        APP__VITALS__START__VALUE,
317        5000.0
318    );
319    outlier_test!(test_outlier_keeps_ttid, APP__VITALS__TTID__VALUE, 5000.0);
320    outlier_test!(test_outlier_keeps_ttfd, APP__VITALS__TTFD__VALUE, 5000.0);
321
322    #[test]
323    fn test_app_start_cold_normalized() {
324        let mut attributes = Annotated::new(attributes! {
325            "app.vitals.start.cold.value" => 1234.0,
326        });
327
328        normalize_mobile_attributes(&mut attributes);
329
330        assert_annotated_snapshot!(attributes, @r#"
331        {
332          "app.vitals.start.cold.value": {
333            "type": "double",
334            "value": 1234.0
335          },
336          "app.vitals.start.type": {
337            "type": "string",
338            "value": "cold"
339          },
340          "app.vitals.start.value": {
341            "type": "double",
342            "value": 1234.0
343          }
344        }
345        "#);
346    }
347
348    #[test]
349    fn test_app_start_warm_normalized() {
350        let mut attributes = Annotated::new(attributes! {
351            "app.vitals.start.warm.value" => 567.0,
352        });
353
354        normalize_mobile_attributes(&mut attributes);
355
356        assert_annotated_snapshot!(attributes, @r#"
357        {
358          "app.vitals.start.type": {
359            "type": "string",
360            "value": "warm"
361          },
362          "app.vitals.start.value": {
363            "type": "double",
364            "value": 567.0
365          },
366          "app.vitals.start.warm.value": {
367            "type": "double",
368            "value": 567.0
369          }
370        }
371        "#);
372    }
373
374    #[test]
375    fn test_app_start_v2_not_overwritten() {
376        let mut attributes = Annotated::new(attributes! {
377            APP__VITALS__START__VALUE => 999.0,
378            APP__VITALS__START__TYPE => "warm",
379            APP__VITALS__START__COLD__VALUE => 1234.0,
380        });
381
382        normalize_mobile_attributes(&mut attributes);
383
384        assert_annotated_snapshot!(attributes, @r#"
385        {
386          "app.vitals.start.cold.value": {
387            "type": "double",
388            "value": 1234.0
389          },
390          "app.vitals.start.type": {
391            "type": "string",
392            "value": "warm"
393          },
394          "app.vitals.start.value": {
395            "type": "double",
396            "value": 999.0
397          }
398        }
399        "#);
400    }
401
402    #[test]
403    fn test_device_class_iphone() {
404        let mut attributes = Annotated::new(attributes! {
405            DEVICE__FAMILY => "iPhone",
406            DEVICE__MODEL => "iPhone17,5",
407        });
408
409        normalize_mobile_attributes(&mut attributes);
410
411        assert_annotated_snapshot!(attributes, @r#"
412        {
413          "device.class": {
414            "type": "string",
415            "value": "3"
416          },
417          "device.family": {
418            "type": "string",
419            "value": "iPhone"
420          },
421          "device.model": {
422            "type": "string",
423            "value": "iPhone17,5"
424          }
425        }
426        "#);
427    }
428
429    #[test]
430    fn test_device_class_android() {
431        let mut attributes = Annotated::new(attributes! {
432            DEVICE__FAMILY => "Android",
433            DEVICE__PROCESSOR_FREQUENCY => 3000.0,
434            DEVICE__PROCESSOR_COUNT => 8.0,
435            DEVICE__MEMORY_SIZE => 8_589_934_592.0,
436        });
437
438        normalize_mobile_attributes(&mut attributes);
439
440        assert_annotated_snapshot!(attributes, @r#"
441        {
442          "device.class": {
443            "type": "string",
444            "value": "3"
445          },
446          "device.family": {
447            "type": "string",
448            "value": "Android"
449          },
450          "device.memory_size": {
451            "type": "double",
452            "value": 8589934592.0
453          },
454          "device.processor_count": {
455            "type": "double",
456            "value": 8.0
457          },
458          "device.processor_frequency": {
459            "type": "double",
460            "value": 3000.0
461          }
462        }
463        "#);
464    }
465
466    #[test]
467    fn test_device_class_missing_attrs() {
468        let mut attributes = Annotated::new(attributes! {
469            DEVICE__FAMILY => "Android",
470        });
471
472        normalize_mobile_attributes(&mut attributes);
473
474        assert_annotated_snapshot!(attributes, @r#"
475        {
476          "device.family": {
477            "type": "string",
478            "value": "Android"
479          }
480        }
481        "#);
482    }
483}