From 323fd4af9e707f49274e0dc986bec95abd94c440 Mon Sep 17 00:00:00 2001 From: Jason Evans Date: Tue, 18 Aug 2026 18:41:48 -0400 Subject: [PATCH] Emit browser_family device signal in auction telemetry Derive a coarse browser family (chrome, safari, firefox, edge, opera) from the User-Agent in DeviceSignals, next to is_mobile and platform_class. Order the checks so Edge, Opera, and the iOS Chrome/Firefox agents classify correctly despite embedding Chrome/Safari tokens. Carry browser_family through the auction observation context onto AuctionEventRow so it ships in the Tinybird NDJSON, and add the matching browser_family column to the auction_events_raw datasource. The value is a low-cardinality family label only; no raw User-Agent leaves the edge, consistent with the existing device-signal privacy stance. The field is forward-only: rows emitted before deploy stay null. --- .../src/tinybird.rs | 1 + .../src/auction/telemetry.rs | 7 ++ crates/trusted-server-core/src/ec/device.rs | 95 +++++++++++++++++++ .../datasources/auction_events_raw.datasource | 1 + 4 files changed, 104 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index f2df61744..636a1b63f 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -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()), diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index b3e049eaf..fd3328570 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -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, /// Whether GDPR applies. pub gdpr_applies: bool, /// Whether any consent signal was present. @@ -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, @@ -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, @@ -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, /// `0` or `1`. pub gdpr_applies: u8, /// `0` or `1`. @@ -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, diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index fbefa9586..352f3eddc 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -33,6 +33,9 @@ pub struct DeviceSignals { /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`, /// `"linux"`. pub platform_class: Option, + /// Coarse browser family from the UA: `"chrome"`, `"safari"`, + /// `"firefox"`, `"edge"`, `"opera"`; `None` when unrecognized. + pub browser_family: Option, /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS string. pub h2_fp_hash: Option, /// `true` = known browser, `false` = known bot, `None` = unknown. @@ -50,6 +53,7 @@ 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()); @@ -57,6 +61,7 @@ impl DeviceSignals { is_mobile, ja4_class, platform_class, + browser_family, h2_fp_hash, known_browser, } @@ -146,6 +151,35 @@ fn parse_platform_class(ua: &str) -> Option { 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 { + 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. @@ -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"; diff --git a/tinybird/datasources/auction_events_raw.datasource b/tinybird/datasources/auction_events_raw.datasource index d62f8ae5d..32959bbd2 100644 --- a/tinybird/datasources/auction_events_raw.datasource +++ b/tinybird/datasources/auction_events_raw.datasource @@ -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)),