relay_event_normalization/normalize/
user_agent.rs

1//! Generate context data from user agent and client hints.
2//!
3//! This module is responsible for taking the user agent string parsing it and filling in
4//! the browser, os and device information in the event.
5//!
6//! ## NOTICE
7//!
8//! Adding user_agent parsing to your module will incur a latency penalty in the test suite.
9//! Because of this some integration tests may fail. To fix this, you will need to add a timeout
10//! to your consumer.
11
12use std::borrow::Cow;
13use std::fmt::Write;
14use std::sync::OnceLock;
15
16use regex::Regex;
17use relay_event_schema::protocol::{
18    BrowserContext, Context, Contexts, DefaultContext, DeviceContext, Event, HeaderName,
19    HeaderValue, Headers, OsContext,
20};
21use relay_protocol::Annotated;
22use serde::{Deserialize, Serialize};
23
24/// Generates context data from client hints or user agent.
25pub fn normalize_user_agent(event: &mut Event) {
26    let headers = match event
27        .request
28        .value()
29        .and_then(|request| request.headers.value())
30    {
31        Some(headers) => headers,
32        None => return,
33    };
34
35    let user_agent_info = RawUserAgentInfo::from_headers(headers);
36
37    let contexts = event.contexts.get_or_insert_with(Contexts::new);
38    normalize_user_agent_info_generic(contexts, &event.platform, &user_agent_info);
39}
40
41/// Low-level version of [`normalize_user_agent`] operating on parts.
42///
43/// This can be used to create contexts from client information without a full [`Event`] instance.
44pub fn normalize_user_agent_info_generic(
45    contexts: &mut Contexts,
46    platform: &Annotated<String>,
47    user_agent_info: &RawUserAgentInfo<&str>,
48) {
49    if !contexts.contains::<BrowserContext>()
50        && let Some(browser_context) = BrowserContext::from_hints_or_ua(user_agent_info)
51    {
52        contexts.add(browser_context);
53    }
54
55    if !contexts.contains::<DeviceContext>()
56        && let Some(device_context) = DeviceContext::from_hints_or_ua(user_agent_info)
57    {
58        contexts.add(device_context);
59    }
60
61    // avoid conflicts with OS-context sent by a serverside SDK by using `contexts.client_os`
62    // instead of `contexts.os`. This is then preferred by the UI to show alongside device and
63    // browser context.
64    //
65    // Why not move the existing `contexts.os` into a different key on conflicts? Because we still
66    // want to index (derive tags from) the SDK-sent context.
67    let os_context_key = match platform.as_str() {
68        Some("javascript") => OsContext::default_key(),
69        _ => "client_os",
70    };
71
72    if !contexts.contains_key(os_context_key)
73        && let Some(os_context) = OsContext::from_hints_or_ua(user_agent_info)
74    {
75        contexts.insert(os_context_key.to_owned(), Context::Os(Box::new(os_context)));
76    }
77}
78
79fn is_known(family: &str) -> bool {
80    family != "Other"
81}
82
83fn get_version(
84    major: &Option<Cow<'_, str>>,
85    minor: &Option<Cow<'_, str>>,
86    patch: &Option<Cow<'_, str>>,
87) -> Option<String> {
88    let mut version = major.as_ref()?.to_string();
89
90    if let Some(minor) = minor {
91        write!(version, ".{minor}").ok();
92        if let Some(patch) = patch {
93            write!(version, ".{patch}").ok();
94        }
95    }
96
97    Some(version)
98}
99
100/// A container housing both the user-agent string and the client hint headers.
101///
102/// Useful for the scenarios where you will use either information from client hints if it exists,
103/// and if not fall back to user agent string.
104#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
105pub struct RawUserAgentInfo<S: Default + AsRef<str>> {
106    /// The "old style" of a single UA string.
107    pub user_agent: Option<S>,
108    /// User-Agent client hints.
109    pub client_hints: ClientHints<S>,
110}
111
112impl<S: AsRef<str> + Default> RawUserAgentInfo<S> {
113    /// Checks if key matches a user agent header, in which case it sets the value accordingly.
114    ///  TODO(tor): make it generic over different header types.
115    pub fn set_ua_field_from_header(&mut self, key: &str, value: Option<S>) {
116        match key.to_lowercase().as_str() {
117            "user-agent" => self.user_agent = value,
118
119            "sec-ch-ua" => self.client_hints.sec_ch_ua = value,
120            "sec-ch-ua-model" => self.client_hints.sec_ch_ua_model = value,
121            "sec-ch-ua-platform" => self.client_hints.sec_ch_ua_platform = value,
122            "sec-ch-ua-platform-version" => {
123                self.client_hints.sec_ch_ua_platform_version = value;
124            }
125            _ => {}
126        }
127    }
128
129    /// Convert user-agent info to HTTP headers as stored in the `Request` interface.
130    ///
131    /// This function does not overwrite any pre-existing headers.
132    pub fn populate_event_headers(&self, headers: &mut Headers) {
133        let mut insert_header = |key: &str, val: Option<&S>| {
134            if let Some(val) = val
135                && !headers.contains(key)
136            {
137                headers.insert(HeaderName::new(key), Annotated::new(HeaderValue::new(val)));
138            }
139        };
140
141        insert_header(RawUserAgentInfo::USER_AGENT, self.user_agent.as_ref());
142        insert_header(
143            ClientHints::SEC_CH_UA_PLATFORM,
144            self.client_hints.sec_ch_ua_platform.as_ref(),
145        );
146        insert_header(
147            ClientHints::SEC_CH_UA_PLATFORM_VERSION,
148            self.client_hints.sec_ch_ua_platform_version.as_ref(),
149        );
150        insert_header(ClientHints::SEC_CH_UA, self.client_hints.sec_ch_ua.as_ref());
151        insert_header(
152            ClientHints::SEC_CH_UA_MODEL,
153            self.client_hints.sec_ch_ua_model.as_ref(),
154        );
155    }
156
157    /// Returns `true`, if neither a user agent nor client hints are available.
158    pub fn is_empty(&self) -> bool {
159        self.user_agent.is_none() && self.client_hints.is_empty()
160    }
161}
162
163impl RawUserAgentInfo<String> {
164    /// The name of the user agent HTTP header.
165    pub const USER_AGENT: &'static str = "User-Agent";
166
167    /// Converts to a borrowed `RawUserAgentInfo`.
168    pub fn as_deref(&self) -> RawUserAgentInfo<&str> {
169        RawUserAgentInfo::<&str> {
170            user_agent: self.user_agent.as_deref(),
171            client_hints: self.client_hints.as_deref(),
172        }
173    }
174}
175
176impl<'a> RawUserAgentInfo<&'a str> {
177    /// Computes a borrowed `RawUserAgentInfo` from the given HTTP headers.
178    ///
179    /// This extracts both the user agent as well as client hints if available. Use
180    /// [`is_empty`](Self::is_empty) to check whether information could be extracted.
181    pub fn from_headers(headers: &'a Headers) -> Self {
182        let mut contexts: RawUserAgentInfo<&str> = Self::default();
183
184        for item in headers.iter() {
185            if let Some((o_k, v)) = item.value()
186                && let Some(k) = o_k.as_str()
187            {
188                contexts.set_ua_field_from_header(k, v.as_str());
189            }
190        }
191        contexts
192    }
193}
194
195/// The client hint variable names mirror the name of the "SEC-CH" headers.
196///
197/// See <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#user_agent_client_hints>
198#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)]
199pub struct ClientHints<S>
200where
201    S: Default + AsRef<str>,
202{
203    /// The client's OS, e.g. macos, android...
204    pub sec_ch_ua_platform: Option<S>,
205    /// The version number of the client's OS.
206    pub sec_ch_ua_platform_version: Option<S>,
207    /// Name of the client's web browser and its version.
208    pub sec_ch_ua: Option<S>,
209    /// Device model, e.g. samsung galaxy 3.
210    pub sec_ch_ua_model: Option<S>,
211}
212
213impl<S> ClientHints<S>
214where
215    S: AsRef<str> + Default,
216{
217    /// Checks every field of a passed-in ClientHints instance if it contains a value, and if it
218    /// does, copy it to self.
219    pub fn copy_from(&mut self, other: ClientHints<S>) {
220        if other.sec_ch_ua_platform_version.is_some() {
221            self.sec_ch_ua_platform_version = other.sec_ch_ua_platform_version;
222        }
223        if other.sec_ch_ua_platform.is_some() {
224            self.sec_ch_ua_platform = other.sec_ch_ua_platform;
225        }
226        if other.sec_ch_ua_model.is_some() {
227            self.sec_ch_ua_model = other.sec_ch_ua_model;
228        }
229        if other.sec_ch_ua.is_some() {
230            self.sec_ch_ua = other.sec_ch_ua;
231        }
232    }
233
234    /// Checks if every field is of value None.
235    pub fn is_empty(&self) -> bool {
236        self.sec_ch_ua_platform.is_none()
237            && self.sec_ch_ua_platform_version.is_none()
238            && self.sec_ch_ua.is_none()
239            && self.sec_ch_ua_model.is_none()
240    }
241}
242
243impl ClientHints<String> {
244    /// Provides the platform or operating system on which the user agent is running.
245    ///
246    /// For example: `"Windows"` or `"Android"`.
247    ///
248    /// `Sec-CH-UA-Platform` is a low entropy hint. Unless blocked by a user agent permission
249    /// policy, it is sent by default (without the server opting in by sending `Accept-CH`).
250    pub const SEC_CH_UA_PLATFORM: &'static str = "SEC-CH-UA-Platform";
251
252    /// Provides the version of the operating system on which the user agent is running.
253    pub const SEC_CH_UA_PLATFORM_VERSION: &'static str = "SEC-CH-UA-Platform-Version";
254
255    /// Provides the user agent's branding and significant version information.
256    ///
257    /// A brand is a commercial name for the user agent like: Chromium, Opera, Google Chrome,
258    /// Microsoft Edge, Firefox, and Safari. A user agent might have several associated brands. For
259    /// example, Opera, Chrome, and Edge are all based on Chromium, and will provide both brands in
260    /// the `Sec-CH-UA` header.
261    ///
262    /// The significant version is the "marketing" version identifier that is used to distinguish
263    /// between major releases of the brand. For example a Chromium build with full version number
264    /// "96.0.4664.45" has a significant version number of "96".
265    ///
266    /// The header may include "fake" brands in any position and with any name. This is a feature
267    /// designed to prevent servers from rejecting unknown user agents outright, forcing user agents
268    /// to lie about their brand identity.
269    ///
270    /// `Sec-CH-UA` is a low entropy hint. Unless blocked by a user agent permission policy, it is
271    /// sent by default (without the server opting in by sending `Accept-CH`).
272    pub const SEC_CH_UA: &'static str = "SEC-CH-UA";
273
274    /// Indicates the device model on which the browser is running.
275    pub const SEC_CH_UA_MODEL: &'static str = "SEC-CH-UA-Model";
276
277    /// Returns an instance of `ClientHints` that borrows from the original data.
278    pub fn as_deref(&self) -> ClientHints<&str> {
279        ClientHints {
280            sec_ch_ua_platform: self.sec_ch_ua_platform.as_deref(),
281            sec_ch_ua_platform_version: self.sec_ch_ua_platform_version.as_deref(),
282            sec_ch_ua: self.sec_ch_ua.as_deref(),
283            sec_ch_ua_model: self.sec_ch_ua_model.as_deref(),
284        }
285    }
286}
287
288impl ClientHints<&str> {
289    /// Creates owned [`ClientHints`].
290    pub fn to_owned(&self) -> ClientHints<String> {
291        ClientHints {
292            sec_ch_ua_platform: self.sec_ch_ua_platform.map(Into::into),
293            sec_ch_ua_platform_version: self.sec_ch_ua_platform_version.map(Into::into),
294            sec_ch_ua: self.sec_ch_ua.map(Into::into),
295            sec_ch_ua_model: self.sec_ch_ua_model.map(Into::into),
296        }
297    }
298}
299
300/// Computes a [`Context`] from either a user agent string and client hints.
301pub trait FromUserAgentInfo: Sized {
302    /// Tries to populate the context from client hints.
303    fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self>;
304
305    /// Tries to populate the context from a user agent header string.
306    fn parse_user_agent(user_agent: &str) -> Option<Self>;
307
308    /// Tries to populate the context from client hints or a user agent header string.
309    fn from_hints_or_ua(raw_info: &RawUserAgentInfo<&str>) -> Option<Self> {
310        Self::parse_client_hints(&raw_info.client_hints)
311            .or_else(|| raw_info.user_agent.and_then(Self::parse_user_agent))
312    }
313}
314
315impl FromUserAgentInfo for DeviceContext {
316    fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
317        let device = client_hints.sec_ch_ua_model?.trim().replace('\"', "");
318
319        if device.is_empty() {
320            return None;
321        }
322
323        Some(Self {
324            model: Annotated::new(device),
325            ..Default::default()
326        })
327    }
328
329    fn parse_user_agent(user_agent: &str) -> Option<Self> {
330        let device = relay_ua::parse_device(user_agent);
331
332        if !is_known(&device.family) {
333            return None;
334        }
335
336        Some(Self {
337            family: Annotated::new(device.family.into_owned()),
338            model: Annotated::from(device.model.map(|cow| cow.into_owned())),
339            brand: Annotated::from(device.brand.map(|cow| cow.into_owned())),
340            ..DeviceContext::default()
341        })
342    }
343}
344
345impl FromUserAgentInfo for BrowserContext {
346    fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
347        let (mut browser, version) = browser_from_client_hints(client_hints.sec_ch_ua?)?;
348
349        // Normalize "Google Chrome" to just "Chrome"
350        if browser == "Google Chrome" {
351            browser = "Chrome".to_owned();
352        }
353
354        Some(Self {
355            name: Annotated::new(browser),
356            version: Annotated::new(version),
357            ..Default::default()
358        })
359    }
360
361    fn parse_user_agent(user_agent: &str) -> Option<Self> {
362        let mut browser = relay_ua::parse_user_agent(user_agent);
363
364        if !is_known(&browser.family) {
365            return None;
366        }
367
368        // Normalize "Google Chrome" to just "Chrome"
369        if browser.family == "Google Chrome" {
370            browser.family = "Chrome".into();
371        }
372
373        Some(Self {
374            name: Annotated::from(browser.family.into_owned()),
375            version: Annotated::from(get_version(&browser.major, &browser.minor, &browser.patch)),
376            ..BrowserContext::default()
377        })
378    }
379}
380
381/// The sec-ch-ua field looks something like this:
382/// "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
383/// The order of the items are randomly shuffled.
384///
385/// It tries to detect the "not a brand" item and the browser engine, if it's neither its assumed
386/// to be a browser and gets returned as such.
387///
388/// Returns None if no browser field detected.
389pub fn browser_from_client_hints(s: &str) -> Option<(String, String)> {
390    static UA_RE: OnceLock<Regex> = OnceLock::new();
391    let regex = UA_RE.get_or_init(|| Regex::new(r#""([^"]*)";v="([^"]*)""#).unwrap());
392    for item in s.split(',') {
393        // if it contains one of these then we can know it isn't a browser field. atm chromium
394        // browsers are the only ones supporting client hints.
395        if item.contains("Brand")
396            || item.contains("Chromium")
397            || item.contains("Gecko") // useless until firefox and safari support client hints
398            || item.contains("WebKit")
399        {
400            continue;
401        }
402
403        let captures = regex.captures(item)?;
404
405        let browser = captures.get(1)?.as_str().to_owned();
406        let version = captures.get(2)?.as_str().to_owned();
407
408        if browser.trim().is_empty() || version.trim().is_empty() {
409            return None;
410        }
411
412        return Some((browser, version));
413    }
414    None
415}
416
417impl FromUserAgentInfo for OsContext {
418    fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
419        let platform = client_hints.sec_ch_ua_platform?.trim().replace('\"', "");
420
421        // We only return early if the platform is empty, not the version number. This is because
422        // an empty version number might suggest that the user need to request additional
423        // client hints data.
424        if platform.is_empty() {
425            return None;
426        }
427
428        let version = client_hints
429            .sec_ch_ua_platform_version
430            .map(|version| version.trim().replace('\"', ""));
431
432        Some(Self {
433            name: Annotated::new(platform),
434            version: Annotated::from(version),
435            ..Default::default()
436        })
437    }
438
439    fn parse_user_agent(user_agent: &str) -> Option<Self> {
440        let os = relay_ua::parse_os(user_agent);
441        let mut version = get_version(&os.major, &os.minor, &os.patch);
442
443        if !is_known(&os.family) {
444            return None;
445        }
446
447        let name = os.family.into_owned();
448
449        // Since user-agent strings freeze the OS-version at windows 10 and mac os 10.15.7,
450        // we will indicate that the version may in reality be higher.
451        if name == "Windows" {
452            if let Some(v) = version.as_mut()
453                && v == "10"
454            {
455                v.insert_str(0, ">=");
456            }
457        } else if name == "Mac OS X"
458            && let Some(v) = version.as_mut()
459            && v == "10.15.7"
460        {
461            v.insert_str(0, ">=");
462        }
463
464        Some(Self {
465            name: Annotated::new(name),
466            version: Annotated::from(version),
467            ..OsContext::default()
468        })
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use relay_event_schema::protocol::{PairList, Request};
475    use relay_protocol::assert_annotated_snapshot;
476
477    use super::*;
478
479    /// Creates an Event with the specified user agent.
480    fn get_event_with_user_agent(user_agent: &str) -> Event {
481        let headers = vec![
482            Annotated::new((
483                Annotated::new("Accept".to_owned().into()),
484                Annotated::new("application/json".to_owned().into()),
485            )),
486            Annotated::new((
487                Annotated::new("UsEr-AgeNT".to_owned().into()),
488                Annotated::new(user_agent.to_owned().into()),
489            )),
490            Annotated::new((
491                Annotated::new("WWW-Authenticate".to_owned().into()),
492                Annotated::new("basic".to_owned().into()),
493            )),
494        ];
495
496        Event {
497            request: Annotated::new(Request {
498                headers: Annotated::new(Headers(PairList(headers))),
499                ..Request::default()
500            }),
501            ..Event::default()
502        }
503    }
504
505    const GOOD_UA: &str = "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19";
506
507    #[test]
508    fn test_version_none() {
509        assert_eq!(get_version(&None, &None, &None), None);
510    }
511
512    #[test]
513    fn test_version_major() {
514        assert_eq!(
515            get_version(&Some("X".into()), &None, &None),
516            Some("X".into())
517        )
518    }
519
520    #[test]
521    fn test_version_major_minor() {
522        assert_eq!(
523            get_version(&Some("X".into()), &Some("Y".into()), &None),
524            Some("X.Y".into())
525        )
526    }
527
528    #[test]
529    fn test_version_major_minor_patch() {
530        assert_eq!(
531            get_version(&Some("X".into()), &Some("Y".into()), &Some("Z".into())),
532            Some("X.Y.Z".into())
533        )
534    }
535
536    #[test]
537    fn test_verison_missing_minor() {
538        assert_eq!(
539            get_version(&Some("X".into()), &None, &Some("Z".into())),
540            Some("X".into())
541        )
542    }
543
544    #[test]
545    fn test_skip_no_user_agent() {
546        let mut event = Event::default();
547        normalize_user_agent(&mut event);
548        assert_eq!(event.contexts.value(), None);
549    }
550
551    #[test]
552    fn test_skip_unrecognizable_user_agent() {
553        let mut event = get_event_with_user_agent("a dont no");
554        normalize_user_agent(&mut event);
555        assert!(event.contexts.value().unwrap().0.is_empty());
556    }
557
558    #[test]
559    fn test_browser_context() {
560        let ua = "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19";
561
562        let mut event = get_event_with_user_agent(ua);
563        normalize_user_agent(&mut event);
564        assert_annotated_snapshot!(event.contexts, @r###"
565        {
566          "browser": {
567            "name": "Chrome Mobile",
568            "version": "18.0.1025",
569            "type": "browser"
570          }
571        }
572        "###);
573    }
574
575    #[test]
576    fn test_os_context() {
577        let ua = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) - -";
578
579        let mut event = get_event_with_user_agent(ua);
580        normalize_user_agent(&mut event);
581        assert_annotated_snapshot!(event.contexts, @r###"
582        {
583          "client_os": {
584            "name": "Windows",
585            "version": "7",
586            "type": "os"
587          }
588        }
589        "###);
590    }
591
592    #[test]
593    fn test_os_context_short_version() {
594        let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) - (-)";
595        let mut event = get_event_with_user_agent(ua);
596        normalize_user_agent(&mut event);
597        assert_annotated_snapshot!(event.contexts, @r###"
598        {
599          "browser": {
600            "name": "Mobile Safari UI/WKWebView",
601            "type": "browser"
602          },
603          "client_os": {
604            "name": "iOS",
605            "version": "12.1",
606            "type": "os"
607          },
608          "device": {
609            "family": "iPhone",
610            "model": "iPhone",
611            "brand": "Apple",
612            "type": "device"
613          }
614        }
615        "###);
616    }
617
618    #[test]
619    fn test_os_context_full_version() {
620        let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) - (-)";
621        let mut event = get_event_with_user_agent(ua);
622        normalize_user_agent(&mut event);
623        assert_annotated_snapshot!(event.contexts, @r###"
624        {
625          "client_os": {
626            "name": "Mac OS X",
627            "version": "10.13.4",
628            "type": "os"
629          },
630          "device": {
631            "family": "Mac",
632            "model": "Mac",
633            "brand": "Apple",
634            "type": "device"
635          }
636        }
637        "###);
638    }
639
640    #[test]
641    fn test_device_context() {
642        let ua = "- (-; -; Galaxy Nexus Build/IMM76B) - (-) ";
643
644        let mut event = get_event_with_user_agent(ua);
645        normalize_user_agent(&mut event);
646        assert_annotated_snapshot!(event.contexts, @r###"
647        {
648          "device": {
649            "family": "Samsung Galaxy Nexus",
650            "model": "Galaxy Nexus",
651            "brand": "Samsung",
652            "type": "device"
653          }
654        }
655        "###);
656    }
657
658    #[test]
659    fn test_all_contexts() {
660        let mut event = get_event_with_user_agent(GOOD_UA);
661        normalize_user_agent(&mut event);
662        assert_annotated_snapshot!(event.contexts, @r###"
663        {
664          "browser": {
665            "name": "Chrome Mobile",
666            "version": "18.0.1025",
667            "type": "browser"
668          },
669          "client_os": {
670            "name": "Android",
671            "version": "4.0.4",
672            "type": "os"
673          },
674          "device": {
675            "family": "Samsung Galaxy Nexus",
676            "model": "Galaxy Nexus",
677            "brand": "Samsung",
678            "type": "device"
679          }
680        }
681        "###);
682    }
683
684    #[test]
685    fn test_user_agent_does_not_override_prefilled() {
686        let mut event = get_event_with_user_agent(GOOD_UA);
687        let mut contexts = Contexts::new();
688        contexts.add(BrowserContext {
689            name: Annotated::from("BR_FAMILY".to_owned()),
690            version: Annotated::from("BR_VERSION".to_owned()),
691            ..BrowserContext::default()
692        });
693        contexts.add(DeviceContext {
694            family: Annotated::from("DEV_FAMILY".to_owned()),
695            model: Annotated::from("DEV_MODEL".to_owned()),
696            brand: Annotated::from("DEV_BRAND".to_owned()),
697            ..DeviceContext::default()
698        });
699        contexts.add(OsContext {
700            name: Annotated::from("OS_FAMILY".to_owned()),
701            version: Annotated::from("OS_VERSION".to_owned()),
702            ..OsContext::default()
703        });
704
705        event.contexts = Annotated::new(contexts);
706
707        normalize_user_agent(&mut event);
708        assert_annotated_snapshot!(event.contexts, @r###"
709        {
710          "browser": {
711            "name": "BR_FAMILY",
712            "version": "BR_VERSION",
713            "type": "browser"
714          },
715          "client_os": {
716            "name": "Android",
717            "version": "4.0.4",
718            "type": "os"
719          },
720          "device": {
721            "family": "DEV_FAMILY",
722            "model": "DEV_MODEL",
723            "brand": "DEV_BRAND",
724            "type": "device"
725          },
726          "os": {
727            "name": "OS_FAMILY",
728            "version": "OS_VERSION",
729            "type": "os"
730          }
731        }
732        "###);
733    }
734
735    #[test]
736    fn test_fallback_to_ua_if_no_client_hints() {
737        let headers = Headers([
738            Annotated::new((
739                Annotated::new("user-agent".to_owned().into()),
740                Annotated::new(r#""Mozilla/5.0 (Linux; Android 11; foo g31(w)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Mobile Safari/537.36""#.to_owned().into()),
741            )),
742            Annotated::new((
743                Annotated::new("invalid header".to_owned().into()),
744                Annotated::new("moto g31(w)".to_owned().into()),
745            )),
746        ].into_iter().collect());
747
748        let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
749        assert_eq!(device.unwrap().family.as_str().unwrap(), "foo g31(w)");
750    }
751    #[test]
752    fn test_use_client_hints_for_device() {
753        let headers = Headers([
754            Annotated::new((
755                Annotated::new("user-agent".to_owned().into()),
756                Annotated::new(r#""Mozilla/5.0 (Linux; Android 11; foo g31(w)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Mobile Safari/537.36""#.to_owned().into()),
757            )),
758            Annotated::new((
759                Annotated::new("SEC-CH-UA-MODEL".to_owned().into()),
760                Annotated::new("moto g31(w)".to_owned().into()),
761            )),
762        ].into_iter().collect());
763
764        let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
765        assert_eq!(device.unwrap().model.as_str().unwrap(), "moto g31(w)");
766    }
767
768    #[test]
769    fn test_strip_whitespace_and_quotes() {
770        let headers = Headers(
771            [Annotated::new((
772                Annotated::new("SEC-CH-UA-MODEL".to_owned().into()),
773                Annotated::new("   \"moto g31(w)\"".to_owned().into()),
774            ))]
775            .into_iter()
776            .collect(),
777        );
778
779        let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
780        assert_eq!(device.unwrap().model.as_str().unwrap(), "moto g31(w)");
781    }
782
783    #[test]
784    fn test_ignore_empty_device() {
785        let headers = Headers(
786            [Annotated::new((
787                Annotated::new("SEC-CH-UA-MODEL".to_owned().into()),
788                Annotated::new("".to_owned().into()),
789            ))]
790            .into_iter()
791            .collect(),
792        );
793
794        let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
795        let from_hints = DeviceContext::parse_client_hints(&client_hints);
796        assert!(from_hints.is_none())
797    }
798
799    #[test]
800    fn test_client_hint_parser() {
801        let chrome = browser_from_client_hints(
802            r#"Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"#,
803        )
804        .unwrap();
805        assert_eq!(chrome.0, "Google Chrome".to_owned());
806        assert_eq!(chrome.1, "109".to_owned());
807
808        let opera = browser_from_client_hints(
809            r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#,
810        )
811        .unwrap();
812        assert_eq!(opera.0, "Opera".to_owned());
813        assert_eq!(opera.1, "94".to_owned());
814
815        let mystery_browser = browser_from_client_hints(
816            r#""Chromium";v="108", "mystery-browser";v="94", "Not)A;Brand";v="99""#,
817        )
818        .unwrap();
819
820        assert_eq!(mystery_browser.0, "mystery-browser".to_owned());
821        assert_eq!(mystery_browser.1, "94".to_owned());
822    }
823
824    #[test]
825    fn test_client_hints_detected() {
826        let headers = Headers({
827            let headers = vec![
828            Annotated::new((
829                Annotated::new("user-agent".to_owned().into()),
830                Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_owned().into()),
831            )),
832            Annotated::new((
833                Annotated::new("SEC-CH-UA".to_owned().into()),
834                Annotated::new(r#"Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"#.to_owned().into()),
835            )),
836        ];
837            PairList(headers)
838        });
839
840        let browser =
841            BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
842
843        insta::assert_debug_snapshot!(browser, @r###"
844        BrowserContext {
845            browser: ~,
846            name: "Chrome",
847            version: "109",
848            other: {},
849        }
850        "###);
851    }
852
853    #[test]
854    fn test_ignore_empty_browser() {
855        let headers = Headers({
856            let headers = vec![Annotated::new((
857                Annotated::new("SEC-CH-UA".to_owned().into()),
858                Annotated::new(
859                    // browser field missing
860                    r#"Not_A Brand";v="99", " ";v="109", "Chromium";v="109"#
861                        .to_owned()
862                        .into(),
863                ),
864            ))];
865            PairList(headers)
866        });
867
868        let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
869        let from_hints = BrowserContext::parse_client_hints(&client_hints);
870        assert!(from_hints.is_none())
871    }
872
873    #[test]
874    fn test_client_hints_with_unknown_browser() {
875        let headers = Headers({
876            let headers = vec![
877            Annotated::new((
878                Annotated::new("user-agent".to_owned().into()),
879                Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_owned().into()),
880            )),
881            Annotated::new((
882                Annotated::new("SEC-CH-UA".to_owned().into()),
883                Annotated::new(r#"Not_A Brand";v="99", "weird browser";v="109", "Chromium";v="109"#.to_owned().into()),
884            )),
885            Annotated::new((
886                Annotated::new("SEC-CH-UA-FULL-VERSION".to_owned().into()),
887                Annotated::new("109.0.5414.87".to_owned().into()),
888            )),
889        ];
890            PairList(headers)
891        });
892
893        let browser =
894            BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
895
896        insta::assert_debug_snapshot!(browser, @r###"
897        BrowserContext {
898            browser: ~,
899            name: "weird browser",
900            version: "109",
901            other: {},
902        }
903        "###);
904    }
905
906    #[test]
907    fn fallback_on_ua_string_when_missing_browser_field() {
908        let headers = Headers({
909            let headers = vec![
910            Annotated::new((
911                Annotated::new("user-agent".to_owned().into()),
912                Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_owned().into()),
913            )),
914            Annotated::new((
915                Annotated::new("SEC-CH-UA".to_owned().into()),
916                Annotated::new(r#"Not_A Brand";v="99", "Chromium";v="108"#.to_owned().into()), // no browser field here
917            )),
918        ];
919            PairList(headers)
920        });
921
922        let browser =
923            BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
924        assert_eq!(
925            browser.version.as_str().unwrap(),
926            "109.0.0" // notice the version number is from UA string not from client hints
927        );
928
929        assert_eq!("Chrome", browser.name.as_str().unwrap());
930    }
931
932    #[test]
933    fn test_strip_quotes() {
934        let headers = Headers({
935            let headers = vec![
936                Annotated::new((
937                    Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
938                    Annotated::new("\"macOS\"".to_owned().into()), // no browser field here
939                )),
940                Annotated::new((
941                    Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
942                    Annotated::new("\"13.1.0\"".to_owned().into()),
943                )),
944            ];
945            PairList(headers)
946        });
947        let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
948
949        assert_eq!(os.clone().unwrap().name.value().unwrap(), "macOS");
950        assert_eq!(os.unwrap().version.value().unwrap(), "13.1.0");
951    }
952
953    /// Verifies that client hints are chosen over ua string when available.
954    #[test]
955    fn test_choose_client_hints_for_os_context() {
956        let headers = Headers({
957            let headers = vec![
958            Annotated::new((
959                Annotated::new("user-agent".to_owned().into()),
960                Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_owned().into()),
961            )),
962            Annotated::new((
963                Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
964                Annotated::new(r#"macOS"#.to_owned().into()), // no browser field here
965            )),
966            Annotated::new((
967                Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
968                Annotated::new("13.1.0".to_owned().into()),
969            )),
970        ];
971            PairList(headers)
972        });
973
974        let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
975
976        insta::assert_debug_snapshot!(os, @r###"
977        OsContext {
978            os: ~,
979            name: "macOS",
980            version: "13.1.0",
981            build: ~,
982            kernel_version: ~,
983            rooted: ~,
984            distribution_name: ~,
985            distribution_version: ~,
986            distribution_pretty_name: ~,
987            raw_description: ~,
988            other: {},
989        }
990        "###);
991    }
992
993    #[test]
994    fn test_ignore_empty_os() {
995        let headers = Headers({
996            let headers = vec![Annotated::new((
997                Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
998                Annotated::new(r#""#.to_owned().into()),
999            ))];
1000            PairList(headers)
1001        });
1002
1003        let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
1004        let from_hints = OsContext::parse_client_hints(&client_hints);
1005        assert!(from_hints.is_none())
1006    }
1007
1008    #[test]
1009    fn test_keep_empty_os_version() {
1010        let headers = Headers({
1011            let headers = vec![
1012                Annotated::new((
1013                    Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
1014                    Annotated::new(r#"macOs"#.to_owned().into()),
1015                )),
1016                Annotated::new((
1017                    Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
1018                    Annotated::new("".to_owned().into()),
1019                )),
1020            ];
1021            PairList(headers)
1022        });
1023
1024        let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
1025        let from_hints = OsContext::parse_client_hints(&client_hints);
1026        assert!(from_hints.is_some())
1027    }
1028
1029    #[test]
1030    fn test_fallback_on_ua_string_for_os() {
1031        let headers = Headers({
1032            let headers = vec![
1033            Annotated::new((
1034                Annotated::new("user-agent".to_owned().into()),
1035                Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_owned().into()),
1036            )),
1037            Annotated::new((
1038                Annotated::new("invalid header".to_owned().into()),
1039                Annotated::new(r#"macOS"#.to_owned().into()),
1040            )),
1041            Annotated::new((
1042                Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
1043                Annotated::new("13.1.0".to_owned().into()),
1044            )),
1045        ];
1046            PairList(headers)
1047        });
1048
1049        let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1050
1051        insta::assert_debug_snapshot!(os, @r###"
1052        OsContext {
1053            os: ~,
1054            name: "Mac OS X",
1055            version: "10.15.6",
1056            build: ~,
1057            kernel_version: ~,
1058            rooted: ~,
1059            distribution_name: ~,
1060            distribution_version: ~,
1061            distribution_pretty_name: ~,
1062            raw_description: ~,
1063            other: {},
1064        }
1065        "###);
1066    }
1067
1068    #[test]
1069    fn test_indicate_frozen_os_windows() {
1070        let headers = Headers({
1071            let headers = vec![
1072            Annotated::new((
1073                Annotated::new("user-agent".to_owned().into()),
1074                Annotated::new(r#"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"#.to_owned().into()),
1075            )),
1076        ];
1077            PairList(headers)
1078        });
1079
1080        let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1081
1082        // Checks that the '>=' prefix is added.
1083        assert_eq!(os.version.value().unwrap(), ">=10");
1084    }
1085
1086    #[test]
1087    fn test_indicate_frozen_os_mac() {
1088        let headers = Headers({
1089            let headers = vec![
1090            Annotated::new((
1091                Annotated::new("user-agent".to_owned().into()),
1092                Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_owned().into()),
1093            )),
1094        ];
1095            PairList(headers)
1096        });
1097
1098        let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1099
1100        // Checks that the '>=' prefix is added.
1101        assert_eq!(os.version.value().unwrap(), ">=10.15.7");
1102    }
1103
1104    #[test]
1105    fn test_default_empty() {
1106        assert!(RawUserAgentInfo::<&str>::default().is_empty());
1107    }
1108}