Skip to main content

relay_event_schema/protocol/
device_class.rs

1use std::fmt;
2
3use relay_conventions::consts::*;
4use relay_protocol::{Annotated, Empty, FromValue, IntoValue};
5
6use crate::protocol::{Attributes, Contexts, DeviceContext};
7
8#[derive(Clone, Copy, Debug, FromValue, IntoValue, Empty, PartialEq)]
9pub struct DeviceClass(pub u64);
10
11const GIB: u64 = 1024 * 1024 * 1024;
12
13impl DeviceClass {
14    pub const LOW: Self = Self(1);
15    pub const MEDIUM: Self = Self(2);
16    pub const HIGH: Self = Self(3);
17
18    /// Derives the device class from span V2 attributes.
19    ///
20    /// Reads `device.family`, `device.model`, `device.processor_frequency`,
21    /// `device.processor_count`, and `device.memory_size` from the attribute map.
22    pub fn from_attributes(attributes: &Attributes) -> Option<DeviceClass> {
23        let family = attributes.get_value(DEVICE_FAMILY)?.as_str()?;
24
25        if is_apple_family(family) {
26            let model = attributes.get_value(DEVICE_MODEL)?.as_str()?;
27            model_to_class(model)
28        } else {
29            let freq = attributes.get_value(DEVICE_PROCESSOR_FREQUENCY)?.as_f64()? as u64;
30            let proc = attributes.get_value(DEVICE_PROCESSOR_COUNT)?.as_f64()? as u64;
31            let mem = attributes.get_value(DEVICE_MEMORY_SIZE)?.as_f64()? as u64;
32            classify_by_hardware(freq, proc, mem)
33        }
34    }
35
36    pub fn from_contexts(contexts: &Contexts) -> Option<DeviceClass> {
37        let device = contexts.get::<DeviceContext>()?;
38        let family = device.family.value()?;
39
40        if is_apple_family(family) {
41            model_to_class(device.model.as_str()?)
42        } else if let (Some(&freq), Some(&proc), Some(&mem)) = (
43            device.processor_frequency.value(),
44            device.processor_count.value(),
45            device.memory_size.value(),
46        ) {
47            classify_by_hardware(freq, proc, mem)
48        } else {
49            None
50        }
51    }
52}
53
54impl fmt::Display for DeviceClass {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        self.0.fmt(f)
57    }
58}
59
60/// Returns `true` if the device family indicates an Apple device (iPhone/iPad).
61///
62/// Apple devices use model-based classification via [`model_to_class`] rather than
63/// hardware specs, because Apple reports the device family as one of these values
64/// depending on the SDK version and platform context.
65fn is_apple_family(family: &str) -> bool {
66    matches!(family, "iPhone" | "iOS" | "iOS-Device")
67}
68
69/// Classifies a non-Apple device into low/medium/high based on hardware specs.
70///
71/// These classifications are based on an analysis of the mobile devices available
72/// in the market today and are subject to change as the market evolves.
73fn classify_by_hardware(freq: u64, proc: u64, mem: u64) -> Option<DeviceClass> {
74    if freq < 2000 || proc < 8 || mem < 4 * GIB {
75        Some(DeviceClass::LOW)
76    } else if freq < 2500 || mem < 6 * GIB {
77        Some(DeviceClass::MEDIUM)
78    } else {
79        Some(DeviceClass::HIGH)
80    }
81}
82
83fn model_to_class(model: &str) -> Option<DeviceClass> {
84    match model {
85        // iPhones
86        "iPhone1,1" => Some(DeviceClass::LOW),
87        "iPhone1,2" => Some(DeviceClass::LOW),
88        "iPhone2,1" => Some(DeviceClass::LOW),
89        "iPhone3,1" => Some(DeviceClass::LOW),
90        "iPhone3,2" => Some(DeviceClass::LOW),
91        "iPhone3,3" => Some(DeviceClass::LOW),
92        "iPhone4,1" => Some(DeviceClass::LOW),
93        "iPhone5,1" => Some(DeviceClass::LOW),
94        "iPhone5,2" => Some(DeviceClass::LOW),
95        "iPhone5,3" => Some(DeviceClass::LOW),
96        "iPhone5,4" => Some(DeviceClass::LOW),
97        "iPhone6,1" => Some(DeviceClass::LOW),
98        "iPhone6,2" => Some(DeviceClass::LOW),
99        "iPhone7,1" => Some(DeviceClass::LOW),
100        "iPhone7,2" => Some(DeviceClass::LOW),
101        "iPhone8,1" => Some(DeviceClass::LOW),
102        "iPhone8,2" => Some(DeviceClass::LOW),
103        "iPhone8,4" => Some(DeviceClass::LOW),
104        "iPhone9,1" => Some(DeviceClass::MEDIUM),
105        "iPhone9,3" => Some(DeviceClass::MEDIUM),
106        "iPhone9,2" => Some(DeviceClass::MEDIUM),
107        "iPhone9,4" => Some(DeviceClass::MEDIUM),
108        "iPhone10,1" => Some(DeviceClass::MEDIUM),
109        "iPhone10,4" => Some(DeviceClass::MEDIUM),
110        "iPhone10,2" => Some(DeviceClass::MEDIUM),
111        "iPhone10,5" => Some(DeviceClass::MEDIUM),
112        "iPhone10,3" => Some(DeviceClass::MEDIUM),
113        "iPhone10,6" => Some(DeviceClass::MEDIUM),
114        "iPhone11,8" => Some(DeviceClass::MEDIUM),
115        "iPhone11,2" => Some(DeviceClass::MEDIUM),
116        "iPhone11,4" => Some(DeviceClass::MEDIUM),
117        "iPhone11,6" => Some(DeviceClass::MEDIUM),
118        "iPhone12,1" => Some(DeviceClass::MEDIUM),
119        "iPhone12,3" => Some(DeviceClass::MEDIUM),
120        "iPhone12,5" => Some(DeviceClass::MEDIUM),
121        "iPhone12,8" => Some(DeviceClass::MEDIUM),
122        "iPhone13,1" => Some(DeviceClass::HIGH),
123        "iPhone13,2" => Some(DeviceClass::HIGH),
124        "iPhone13,3" => Some(DeviceClass::HIGH),
125        "iPhone13,4" => Some(DeviceClass::HIGH),
126        "iPhone14,4" => Some(DeviceClass::HIGH),
127        "iPhone14,5" => Some(DeviceClass::HIGH),
128        "iPhone14,2" => Some(DeviceClass::HIGH),
129        "iPhone14,3" => Some(DeviceClass::HIGH),
130        "iPhone14,6" => Some(DeviceClass::HIGH),
131        "iPhone14,7" => Some(DeviceClass::HIGH),
132        "iPhone14,8" => Some(DeviceClass::HIGH),
133        "iPhone15,2" => Some(DeviceClass::HIGH),
134        "iPhone15,3" => Some(DeviceClass::HIGH),
135        "iPhone15,4" => Some(DeviceClass::HIGH),
136        "iPhone15,5" => Some(DeviceClass::HIGH),
137        "iPhone16,1" => Some(DeviceClass::HIGH),
138        "iPhone16,2" => Some(DeviceClass::HIGH),
139        "iPhone17,1" => Some(DeviceClass::HIGH),
140        "iPhone17,2" => Some(DeviceClass::HIGH),
141        "iPhone17,3" => Some(DeviceClass::HIGH),
142        "iPhone17,4" => Some(DeviceClass::HIGH),
143        "iPhone17,5" => Some(DeviceClass::HIGH),
144        "iPhone18,1" => Some(DeviceClass::HIGH),
145        "iPhone18,2" => Some(DeviceClass::HIGH),
146        "iPhone18,3" => Some(DeviceClass::HIGH),
147        "iPhone18,4" => Some(DeviceClass::HIGH),
148        "iPhone18,5" => Some(DeviceClass::HIGH),
149
150        // iPads
151        "iPad1,1" => Some(DeviceClass::LOW),
152        "iPad2,1" => Some(DeviceClass::LOW),
153        "iPad2,2" => Some(DeviceClass::LOW),
154        "iPad2,3" => Some(DeviceClass::LOW),
155        "iPad2,4" => Some(DeviceClass::LOW),
156        "iPad2,5" => Some(DeviceClass::LOW),
157        "iPad2,6" => Some(DeviceClass::LOW),
158        "iPad2,7" => Some(DeviceClass::LOW),
159        "iPad3,1" => Some(DeviceClass::LOW),
160        "iPad3,2" => Some(DeviceClass::LOW),
161        "iPad3,3" => Some(DeviceClass::LOW),
162        "iPad3,4" => Some(DeviceClass::LOW),
163        "iPad3,5" => Some(DeviceClass::LOW),
164        "iPad3,6" => Some(DeviceClass::LOW),
165        "iPad4,1" => Some(DeviceClass::LOW),
166        "iPad4,2" => Some(DeviceClass::LOW),
167        "iPad4,3" => Some(DeviceClass::LOW),
168        "iPad4,4" => Some(DeviceClass::LOW),
169        "iPad4,5" => Some(DeviceClass::LOW),
170        "iPad4,6" => Some(DeviceClass::LOW),
171        "iPad4,7" => Some(DeviceClass::LOW),
172        "iPad4,8" => Some(DeviceClass::LOW),
173        "iPad4,9" => Some(DeviceClass::LOW),
174        "iPad5,1" => Some(DeviceClass::LOW),
175        "iPad5,2" => Some(DeviceClass::LOW),
176        "iPad5,3" => Some(DeviceClass::LOW),
177        "iPad5,4" => Some(DeviceClass::LOW),
178        "iPad6,3" => Some(DeviceClass::MEDIUM),
179        "iPad6,4" => Some(DeviceClass::MEDIUM),
180        "iPad6,7" => Some(DeviceClass::MEDIUM),
181        "iPad6,8" => Some(DeviceClass::MEDIUM),
182        "iPad6,11" => Some(DeviceClass::LOW),
183        "iPad6,12" => Some(DeviceClass::LOW),
184        "iPad7,1" => Some(DeviceClass::MEDIUM),
185        "iPad7,2" => Some(DeviceClass::MEDIUM),
186        "iPad7,3" => Some(DeviceClass::MEDIUM),
187        "iPad7,4" => Some(DeviceClass::MEDIUM),
188        "iPad7,5" => Some(DeviceClass::MEDIUM),
189        "iPad7,6" => Some(DeviceClass::MEDIUM),
190        "iPad7,11" => Some(DeviceClass::MEDIUM),
191        "iPad7,12" => Some(DeviceClass::MEDIUM),
192        "iPad8,1" => Some(DeviceClass::MEDIUM),
193        "iPad8,2" => Some(DeviceClass::MEDIUM),
194        "iPad8,3" => Some(DeviceClass::MEDIUM),
195        "iPad8,4" => Some(DeviceClass::MEDIUM),
196        "iPad8,5" => Some(DeviceClass::MEDIUM),
197        "iPad8,6" => Some(DeviceClass::MEDIUM),
198        "iPad8,7" => Some(DeviceClass::MEDIUM),
199        "iPad8,8" => Some(DeviceClass::MEDIUM),
200        "iPad8,9" => Some(DeviceClass::MEDIUM),
201        "iPad8,10" => Some(DeviceClass::MEDIUM),
202        "iPad8,11" => Some(DeviceClass::MEDIUM),
203        "iPad8,12" => Some(DeviceClass::MEDIUM),
204        "iPad11,1" => Some(DeviceClass::MEDIUM),
205        "iPad11,2" => Some(DeviceClass::MEDIUM),
206        "iPad11,3" => Some(DeviceClass::MEDIUM),
207        "iPad11,4" => Some(DeviceClass::MEDIUM),
208        "iPad11,6" => Some(DeviceClass::MEDIUM),
209        "iPad11,7" => Some(DeviceClass::MEDIUM),
210        "iPad12,1" => Some(DeviceClass::MEDIUM),
211        "iPad12,2" => Some(DeviceClass::MEDIUM),
212        "iPad13,1" => Some(DeviceClass::HIGH),
213        "iPad13,2" => Some(DeviceClass::HIGH),
214        "iPad13,4" => Some(DeviceClass::HIGH),
215        "iPad13,5" => Some(DeviceClass::HIGH),
216        "iPad13,6" => Some(DeviceClass::HIGH),
217        "iPad13,7" => Some(DeviceClass::HIGH),
218        "iPad13,8" => Some(DeviceClass::HIGH),
219        "iPad13,9" => Some(DeviceClass::HIGH),
220        "iPad13,10" => Some(DeviceClass::HIGH),
221        "iPad13,11" => Some(DeviceClass::HIGH),
222        "iPad13,16" => Some(DeviceClass::HIGH),
223        "iPad13,17" => Some(DeviceClass::HIGH),
224        "iPad13,18" => Some(DeviceClass::HIGH),
225        "iPad13,19" => Some(DeviceClass::HIGH),
226        "iPad14,1" => Some(DeviceClass::HIGH),
227        "iPad14,2" => Some(DeviceClass::HIGH),
228        "iPad14,3" => Some(DeviceClass::HIGH),
229        "iPad14,4" => Some(DeviceClass::HIGH),
230        "iPad14,5" => Some(DeviceClass::HIGH),
231        "iPad14,6" => Some(DeviceClass::HIGH),
232        "iPad14,8" => Some(DeviceClass::HIGH),
233        "iPad14,9" => Some(DeviceClass::HIGH),
234        "iPad14,10" => Some(DeviceClass::HIGH),
235        "iPad14,11" => Some(DeviceClass::HIGH),
236        "iPad15,3" => Some(DeviceClass::HIGH),
237        "iPad15,4" => Some(DeviceClass::HIGH),
238        "iPad15,5" => Some(DeviceClass::HIGH),
239        "iPad15,6" => Some(DeviceClass::HIGH),
240        "iPad15,7" => Some(DeviceClass::HIGH),
241        "iPad15,8" => Some(DeviceClass::HIGH),
242        "iPad16,1" => Some(DeviceClass::HIGH),
243        "iPad16,2" => Some(DeviceClass::HIGH),
244        "iPad16,3" => Some(DeviceClass::HIGH),
245        "iPad16,4" => Some(DeviceClass::HIGH),
246        "iPad16,5" => Some(DeviceClass::HIGH),
247        "iPad16,6" => Some(DeviceClass::HIGH),
248        "iPad16,8" => Some(DeviceClass::HIGH),
249        "iPad16,9" => Some(DeviceClass::HIGH),
250        "iPad16,10" => Some(DeviceClass::HIGH),
251        "iPad16,11" => Some(DeviceClass::HIGH),
252        "iPad17,1" => Some(DeviceClass::HIGH),
253        "iPad17,2" => Some(DeviceClass::HIGH),
254        "iPad17,3" => Some(DeviceClass::HIGH),
255        "iPad17,4" => Some(DeviceClass::HIGH),
256
257        // If we don't know the model it's a new device and therefore must be high.
258        _ => Some(DeviceClass::HIGH),
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    macro_rules! attributes {
267        ($($key:expr => $value:expr),* $(,)?) => {
268            Attributes::from([
269                $(($key.into(), Annotated::new($value.into())),)*
270            ])
271        };
272    }
273
274    #[test]
275    fn test_from_attributes_iphone17_5() {
276        let attrs = attributes! {
277            DEVICE_FAMILY => "iOS",
278            DEVICE_MODEL => "iPhone17,5",
279        };
280        assert_eq!(
281            DeviceClass::from_attributes(&attrs),
282            Some(DeviceClass::HIGH)
283        );
284    }
285
286    #[test]
287    fn test_from_attributes_iphone99_1() {
288        let attrs = attributes! {
289            DEVICE_FAMILY => "iOS",
290            DEVICE_MODEL => "iPhone99,1",
291        };
292        assert_eq!(
293            DeviceClass::from_attributes(&attrs),
294            Some(DeviceClass::HIGH)
295        );
296    }
297
298    #[test]
299    fn test_from_attributes_ipad99_1() {
300        let attrs = attributes! {
301            DEVICE_FAMILY => "iOS",
302            DEVICE_MODEL => "iPad99,1",
303        };
304        assert_eq!(
305            DeviceClass::from_attributes(&attrs),
306            Some(DeviceClass::HIGH)
307        );
308    }
309
310    #[test]
311    fn test_from_attributes_garbage_device_model() {
312        let attrs = attributes! {
313            DEVICE_FAMILY => "iOS",
314            DEVICE_MODEL => "garbage-device-model",
315        };
316        assert_eq!(
317            DeviceClass::from_attributes(&attrs),
318            Some(DeviceClass::HIGH)
319        );
320    }
321
322    #[test]
323    fn test_from_attributes_wrong_family() {
324        let attrs = attributes! {
325            DEVICE_FAMILY => "iOSS",
326            DEVICE_MODEL => "iPhone17,5",
327        };
328        assert_eq!(DeviceClass::from_attributes(&attrs), None);
329    }
330
331    #[test]
332    fn test_from_attributes_android_low() {
333        let attrs = attributes! {
334            DEVICE_FAMILY => "Android",
335            DEVICE_PROCESSOR_FREQUENCY => 1500.0,
336            DEVICE_PROCESSOR_COUNT => 4.0,
337            DEVICE_MEMORY_SIZE => 2_147_483_648.0,
338        };
339        assert_eq!(DeviceClass::from_attributes(&attrs), Some(DeviceClass::LOW));
340    }
341
342    #[test]
343    fn test_from_attributes_android_medium() {
344        let attrs = attributes! {
345            DEVICE_FAMILY => "Android",
346            DEVICE_PROCESSOR_FREQUENCY => 2200.0,
347            DEVICE_PROCESSOR_COUNT => 8.0,
348            DEVICE_MEMORY_SIZE => 5_368_709_120.0,
349        };
350        assert_eq!(
351            DeviceClass::from_attributes(&attrs),
352            Some(DeviceClass::MEDIUM)
353        );
354    }
355
356    #[test]
357    fn test_from_attributes_android_high() {
358        let attrs = attributes! {
359            DEVICE_FAMILY => "Android",
360            DEVICE_PROCESSOR_FREQUENCY => 3000.0,
361            DEVICE_PROCESSOR_COUNT => 8.0,
362            DEVICE_MEMORY_SIZE => 8_589_934_592.0,
363        };
364        assert_eq!(
365            DeviceClass::from_attributes(&attrs),
366            Some(DeviceClass::HIGH)
367        );
368    }
369
370    #[test]
371    fn test_from_attributes_android_missing_specs() {
372        let attrs = attributes! {
373            DEVICE_FAMILY => "Android",
374        };
375        assert_eq!(DeviceClass::from_attributes(&attrs), None);
376    }
377
378    #[test]
379    fn test_iphone17_5_returns_device_class_high() {
380        let mut contexts = Contexts::new();
381        contexts.add(DeviceContext {
382            family: Annotated::new("iOS".to_owned()),
383            model: Annotated::new("iPhone17,5".to_owned()),
384            ..DeviceContext::default()
385        });
386        assert_eq!(
387            DeviceClass::from_contexts(&contexts),
388            Some(DeviceClass::HIGH)
389        );
390    }
391
392    #[test]
393    fn test_iphone99_1_returns_device_class_high() {
394        let mut contexts = Contexts::new();
395        contexts.add(DeviceContext {
396            family: Annotated::new("iOS".to_owned()),
397            model: Annotated::new("iPhone99,1".to_owned()),
398            ..DeviceContext::default()
399        });
400        assert_eq!(
401            DeviceClass::from_contexts(&contexts),
402            Some(DeviceClass::HIGH)
403        );
404    }
405
406    #[test]
407    fn test_ipad99_1_returns_device_class_high() {
408        let mut contexts = Contexts::new();
409        contexts.add(DeviceContext {
410            family: Annotated::new("iOS".to_owned()),
411            model: Annotated::new("iPad99,1".to_owned()),
412            ..DeviceContext::default()
413        });
414        assert_eq!(
415            DeviceClass::from_contexts(&contexts),
416            Some(DeviceClass::HIGH)
417        );
418    }
419
420    #[test]
421    fn test_garbage_device_model_returns_device_class_high() {
422        let mut contexts = Contexts::new();
423        contexts.add(DeviceContext {
424            family: Annotated::new("iOS".to_owned()),
425            model: Annotated::new("garbage-device-model".to_owned()),
426            ..DeviceContext::default()
427        });
428        assert_eq!(
429            DeviceClass::from_contexts(&contexts),
430            Some(DeviceClass::HIGH)
431        );
432    }
433
434    #[test]
435    fn test_wrong_family_returns_none() {
436        let mut contexts = Contexts::new();
437        contexts.add(DeviceContext {
438            family: Annotated::new("iOSS".to_owned()),
439            model: Annotated::new("iPhone17,5".to_owned()),
440            ..DeviceContext::default()
441        });
442        assert_eq!(DeviceClass::from_contexts(&contexts), None);
443    }
444}