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