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