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