Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/trusted-server-adapter-fastly/src/tinybird.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ mod tests {
region: None,
is_mobile: 0,
is_known_browser: 1,
browser_family: Some("chrome".to_owned()),
gdpr_applies: 0,
consent_present: 0,
terminal_status: Some("completed".to_owned()),
Expand Down
7 changes: 7 additions & 0 deletions crates/trusted-server-core/src/auction/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ pub struct AuctionObservationContext {
pub is_mobile: u8,
/// `0` = bot, `1` = browser, `2` = unknown.
pub is_known_browser: u8,
/// Coarse browser family from the UA, when recognized.
pub browser_family: Option<String>,
/// Whether GDPR applies.
pub gdpr_applies: bool,
/// Whether any consent signal was present.
Expand Down Expand Up @@ -172,6 +174,7 @@ impl AuctionObservationContext {
Some(false) => 0,
None => 2,
},
browser_family: device.and_then(|signals| signals.browser_family.clone()),
gdpr_applies: consent.gdpr_applies,
consent_present: !consent.is_empty(),
slot_count,
Expand All @@ -196,6 +199,7 @@ impl AuctionObservationContext {
region: Some("CA".to_owned()),
is_mobile: 0,
is_known_browser: 1,
browser_family: Some("chrome".to_owned()),
gdpr_applies: false,
consent_present: false,
slot_count,
Expand Down Expand Up @@ -282,6 +286,8 @@ pub struct AuctionEventRow {
pub is_mobile: u8,
/// `0` = bot, `1` = browser, `2` = unknown.
pub is_known_browser: u8,
/// Coarse browser family from the UA, when recognized.
pub browser_family: Option<String>,
/// `0` or `1`.
pub gdpr_applies: u8,
/// `0` or `1`.
Expand Down Expand Up @@ -341,6 +347,7 @@ impl AuctionEventRow {
region: observation.region.clone(),
is_mobile: observation.is_mobile,
is_known_browser: observation.is_known_browser,
browser_family: observation.browser_family.clone(),
gdpr_applies: u8::from(observation.gdpr_applies),
consent_present: u8::from(observation.consent_present),
terminal_status: None,
Expand Down
95 changes: 95 additions & 0 deletions crates/trusted-server-core/src/ec/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub struct DeviceSignals {
/// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`,
/// `"linux"`.
pub platform_class: Option<String>,
/// Coarse browser family from the UA: `"chrome"`, `"safari"`,
/// `"firefox"`, `"edge"`, `"opera"`; `None` when unrecognized.
pub browser_family: Option<String>,
/// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS string.
pub h2_fp_hash: Option<String>,
/// `true` = known browser, `false` = known bot, `None` = unknown.
Expand All @@ -50,13 +53,15 @@ impl DeviceSignals {
let is_mobile = parse_is_mobile(ua);
let ja4_class = ja4.and_then(extract_ja4_section1);
let platform_class = parse_platform_class(ua);
let browser_family = parse_browser_family(ua);
let h2_fp_hash = h2_fp.map(compute_h2_fp_hash);
let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref());

Self {
is_mobile,
ja4_class,
platform_class,
browser_family,
h2_fp_hash,
known_browser,
}
Expand Down Expand Up @@ -146,6 +151,35 @@ fn parse_platform_class(ua: &str) -> Option<String> {
None
}

/// Parses coarse browser family from the User-Agent string.
///
/// Order matters: Edge, Opera, and the iOS Chrome/Firefox user agents embed
/// `Chrome` and/or `Safari` tokens, so the more specific families are matched
/// before the generic `Chrome` and `Safari` fallbacks.
///
/// Returns `None` when no recognized browser pattern is found (bots, raw HTTP
/// clients, or empty UA).
#[must_use]
fn parse_browser_family(ua: &str) -> Option<String> {
if ua.contains("Edg/") || ua.contains("EdgA/") || ua.contains("EdgiOS/") || ua.contains("Edge/")
{
return Some("edge".to_owned());
}
if ua.contains("OPR/") || ua.contains("Opera") {
return Some("opera".to_owned());
}
if ua.contains("Firefox/") || ua.contains("FxiOS/") {
return Some("firefox".to_owned());
}
if ua.contains("Chrome/") || ua.contains("CriOS/") {
return Some("chrome".to_owned());
}
if ua.contains("Safari/") {
return Some("safari".to_owned());
}
None
}

/// Extracts Section 1 from a full JA4 string.
///
/// JA4 format: `section1_section2_section3` separated by underscores.
Expand Down Expand Up @@ -305,6 +339,67 @@ mod tests {
);
}

#[test]
fn browser_family_recognized() {
assert_eq!(
parse_browser_family(CHROME_MAC_UA).as_deref(),
Some("chrome"),
"Chrome/Mac = chrome"
);
assert_eq!(
parse_browser_family(CHROME_ANDROID_UA).as_deref(),
Some("chrome"),
"Chrome/Android = chrome"
);
assert_eq!(
parse_browser_family(SAFARI_IOS_UA).as_deref(),
Some("safari"),
"Safari/iOS = safari"
);
assert_eq!(
parse_browser_family(SAFARI_MAC_UA).as_deref(),
Some("safari"),
"Safari/Mac = safari"
);
assert_eq!(
parse_browser_family(FIREFOX_MAC_UA).as_deref(),
Some("firefox"),
"Firefox/Mac = firefox"
);
}

#[test]
fn browser_family_edge_and_opera_win_over_chrome() {
let edge = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0";
let opera = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 OPR/120.0.0.0";
assert_eq!(
parse_browser_family(edge).as_deref(),
Some("edge"),
"Edge UA embeds Chrome/Safari but should classify as edge"
);
assert_eq!(
parse_browser_family(opera).as_deref(),
Some("opera"),
"Opera UA embeds Chrome/Safari but should classify as opera"
);
}

#[test]
fn browser_family_unknown() {
assert_eq!(
parse_browser_family(BOT_UA),
None,
"Googlebot = no browser family"
);
assert_eq!(
parse_browser_family(""),
None,
"empty UA = no browser family"
);
}

#[test]
fn platform_class_linux() {
let linux_ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36";
Expand Down
1 change: 1 addition & 0 deletions tinybird/datasources/auction_events_raw.datasource
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ SCHEMA >
`region` Nullable(String),
`is_mobile` UInt8,
`is_known_browser` UInt8,
`browser_family` LowCardinality(Nullable(String)),
`gdpr_applies` UInt8,
`consent_present` UInt8,
`terminal_status` LowCardinality(Nullable(String)),
Expand Down
Loading