Skip to main content

relay_event_normalization/eap/
mobile.rs

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