diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d46533ed..111213b0d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Features**: -- Add support for client hints. ([#1752](https://github.com/getsentry/relay/pull/1752)) +- Use client hint headers instead of User-Agent when available. ([#1752](https://github.com/getsentry/relay/pull/1752), [#1802](https://github.com/getsentry/relay/pull/1802)) - Apply all configured data scrubbing rules on Replays. ([#1731](https://github.com/getsentry/relay/pull/1731)) - Add count transactions toward root project. ([#1734](https://github.com/getsentry/relay/pull/1734)) - Add or remove the profile ID on the transaction's profiling context. ([#1801](https://github.com/getsentry/relay/pull/1801)) diff --git a/relay-cabi/src/processing.rs b/relay-cabi/src/processing.rs index 7173be7942..98b4a61614 100644 --- a/relay-cabi/src/processing.rs +++ b/relay-cabi/src/processing.rs @@ -20,6 +20,7 @@ use relay_general::store::{ light_normalize_event, GeoIpLookup, LightNormalizationConfig, StoreConfig, StoreProcessor, }; use relay_general::types::{Annotated, Remark}; +use relay_general::user_agent::RawUserAgentInfo; use relay_sampling::{RuleCondition, SamplingConfig}; use crate::core::{RelayBuf, RelayStr}; @@ -112,7 +113,10 @@ pub unsafe extern "C" fn relay_store_normalizer_normalize_event( let config = (*processor).config(); let light_normalization_config = LightNormalizationConfig { client_ip: config.client_ip.as_ref(), - user_agent: config.user_agent.as_deref(), + user_agent: RawUserAgentInfo { + user_agent: config.user_agent.as_deref(), + client_hints: config.client_hints.as_deref(), + }, received_at: config.received_at, max_secs_in_past: config.max_secs_in_past, max_secs_in_future: config.max_secs_in_future, diff --git a/relay-general/benches/benchmarks.rs b/relay-general/benches/benchmarks.rs index d479d9928b..95ba5323fe 100644 --- a/relay-general/benches/benchmarks.rs +++ b/relay-general/benches/benchmarks.rs @@ -8,6 +8,7 @@ use relay_general::processor::{process_value, SelectorSpec}; use relay_general::protocol::{Event, IpAddr}; use relay_general::store::{StoreConfig, StoreProcessor}; use relay_general::types::Annotated; +use relay_general::user_agent::ClientHints; fn load_all_fixtures() -> Vec> { let mut rv = Vec::new(); @@ -93,6 +94,7 @@ fn bench_store_processor(c: &mut Criterion) { breakdowns: None, span_attributes: Default::default(), client_sample_rate: None, + client_hints: ClientHints::default(), }; let mut processor = StoreProcessor::new(config, None); diff --git a/relay-general/src/protocol/contexts/browser.rs b/relay-general/src/protocol/contexts/browser.rs index 57c01629e5..b33e9be1e3 100644 --- a/relay-general/src/protocol/contexts/browser.rs +++ b/relay-general/src/protocol/contexts/browser.rs @@ -29,7 +29,7 @@ impl BrowserContext { } impl FromUserAgentInfo for BrowserContext { - fn from_client_hints(client_hints: &user_agent::ClientHints) -> Option { + fn from_client_hints(client_hints: &user_agent::ClientHints<&str>) -> Option { let (browser, version) = browser_from_client_hints(client_hints.sec_ch_ua?)?; Some(Self { diff --git a/relay-general/src/protocol/contexts/device.rs b/relay-general/src/protocol/contexts/device.rs index 57e1fcafec..417e3a9536 100644 --- a/relay-general/src/protocol/contexts/device.rs +++ b/relay-general/src/protocol/contexts/device.rs @@ -173,7 +173,7 @@ impl DeviceContext { } impl FromUserAgentInfo for DeviceContext { - fn from_client_hints(client_hints: &ClientHints) -> Option { + fn from_client_hints(client_hints: &ClientHints<&str>) -> Option { let device = client_hints.sec_ch_ua_model?.to_owned(); if device.trim().is_empty() { diff --git a/relay-general/src/protocol/contexts/mod.rs b/relay-general/src/protocol/contexts/mod.rs index 9c058b7cac..9f957d7b79 100644 --- a/relay-general/src/protocol/contexts/mod.rs +++ b/relay-general/src/protocol/contexts/mod.rs @@ -92,10 +92,10 @@ impl Context { /// With an automatically derived function which tries to first get the context from client hints, /// if that fails it tries for the user agent string. pub trait FromUserAgentInfo: Sized { - fn from_client_hints(client_hints: &ClientHints) -> Option; + fn from_client_hints(client_hints: &ClientHints<&str>) -> Option; fn from_user_agent(user_agent: &str) -> Option; - fn from_hints_or_ua(raw_info: &RawUserAgentInfo) -> Option { + fn from_hints_or_ua(raw_info: &RawUserAgentInfo<&str>) -> Option { Self::from_client_hints(&raw_info.client_hints) .or_else(|| raw_info.user_agent.and_then(Self::from_user_agent)) } diff --git a/relay-general/src/protocol/contexts/os.rs b/relay-general/src/protocol/contexts/os.rs index c376f57b3e..88d1afd461 100644 --- a/relay-general/src/protocol/contexts/os.rs +++ b/relay-general/src/protocol/contexts/os.rs @@ -50,7 +50,7 @@ impl OsContext { } impl FromUserAgentInfo for OsContext { - fn from_client_hints(client_hints: &ClientHints) -> Option { + fn from_client_hints(client_hints: &ClientHints<&str>) -> Option { let platform = client_hints.sec_ch_ua_platform?; let version = client_hints.sec_ch_ua_platform_version?; diff --git a/relay-general/src/protocol/replay.rs b/relay-general/src/protocol/replay.rs index 94d6f406de..ed22ec2348 100644 --- a/relay-general/src/protocol/replay.rs +++ b/relay-general/src/protocol/replay.rs @@ -247,7 +247,11 @@ impl Replay { Ok(()) } - pub fn normalize(&mut self, client_ip: Option, user_agent: Option<&str>) { + pub fn normalize( + &mut self, + client_ip: Option, + user_agent: &RawUserAgentInfo<&str>, + ) { self.normalize_platform(); self.normalize_ip_address(client_ip); self.normalize_user_agent(user_agent); @@ -264,7 +268,7 @@ impl Replay { } } - fn normalize_user_agent(&mut self, default_user_agent: Option<&str>) { + fn normalize_user_agent(&mut self, default_user_agent: &RawUserAgentInfo<&str>) { let headers = match self .request .value() @@ -274,14 +278,16 @@ impl Replay { None => return, }; - let mut user_agent_info = RawUserAgentInfo::from_headers(headers); + let user_agent_info = RawUserAgentInfo::from_headers(headers); - if user_agent_info.user_agent.is_none() { - user_agent_info.user_agent = default_user_agent; - } + let user_agent_info = if user_agent_info.is_empty() { + default_user_agent + } else { + &user_agent_info + }; let contexts = self.contexts.get_or_insert_with(|| Contexts::new()); - user_agent::normalize_user_agent_info_generic(contexts, &self.platform, &user_agent_info); + user_agent::normalize_user_agent_info_generic(contexts, &self.platform, user_agent_info); } fn normalize_platform(&mut self) { @@ -310,6 +316,7 @@ mod tests { }; use crate::testutils::get_value; use crate::types::Annotated; + use crate::user_agent::RawUserAgentInfo; use chrono::{TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr}; @@ -414,7 +421,7 @@ mod tests { let mut replay: Annotated = Annotated::from_json(payload).unwrap(); let replay_value = replay.value_mut().as_mut().unwrap(); - replay_value.normalize(None, None); + replay_value.normalize(None, &RawUserAgentInfo::default()); let loaded_browser_context = replay_value .contexts @@ -448,7 +455,7 @@ mod tests { let mut replay: Annotated = Annotated::from_json(payload).unwrap(); let replay_value = replay.value_mut().as_mut().unwrap(); - replay_value.normalize(None, None); + replay_value.normalize(None, &RawUserAgentInfo::default()); let user = replay_value.user.value(); assert!(user.is_none()); @@ -464,7 +471,7 @@ mod tests { let mut replay: Annotated = Annotated::from_json(payload).unwrap(); let replay_value = replay.value_mut().as_mut().unwrap(); - replay_value.normalize(Some(ip_address), None); + replay_value.normalize(Some(ip_address), &RawUserAgentInfo::default()); let ipaddr = replay_value .user @@ -484,7 +491,7 @@ mod tests { let mut replay: Annotated = Annotated::from_json(payload).unwrap(); let replay_value = replay.value_mut().as_mut().unwrap(); - replay_value.normalize(None, None); + replay_value.normalize(None, &RawUserAgentInfo::default()); let user = replay_value.user.value().unwrap(); assert!(user.ip_address.value().unwrap().as_str() == "127.1.1.1"); diff --git a/relay-general/src/store/mod.rs b/relay-general/src/store/mod.rs index 5c9317f5fd..11eafec68e 100644 --- a/relay-general/src/store/mod.rs +++ b/relay-general/src/store/mod.rs @@ -9,6 +9,7 @@ use serde_json::Value; use crate::processor::{ProcessingState, Processor}; use crate::protocol::{Event, IpAddr}; use crate::types::{Meta, ProcessingResult, SpanAttribute}; +use crate::user_agent::ClientHints; mod clock_drift; mod event_error; @@ -38,6 +39,7 @@ pub struct StoreConfig { pub protocol_version: Option, pub grouping_config: Option, pub user_agent: Option, + pub client_hints: ClientHints, pub received_at: Option>, pub sent_at: Option>, diff --git a/relay-general/src/store/normalize.rs b/relay-general/src/store/normalize.rs index 15eb7953e6..e79b5b68cc 100644 --- a/relay-general/src/store/normalize.rs +++ b/relay-general/src/store/normalize.rs @@ -16,15 +16,15 @@ use super::{schema, transactions, BreakdownsConfig, TransactionNameRule}; use crate::processor::{MaxChars, ProcessValue, ProcessingState, Processor}; use crate::protocol::{ self, AsPair, Breadcrumb, ClientSdkInfo, Context, Contexts, DebugImage, Event, EventId, - EventType, Exception, Frame, HeaderName, HeaderValue, Headers, IpAddr, Level, LogEntry, - Measurement, Measurements, Request, SpanStatus, Stacktrace, Tags, TraceContext, User, - VALID_PLATFORMS, + EventType, Exception, Frame, Headers, IpAddr, Level, LogEntry, Measurement, Measurements, + Request, SpanStatus, Stacktrace, Tags, TraceContext, User, VALID_PLATFORMS, }; use crate::store::{ClockDriftProcessor, GeoIpLookup, StoreConfig}; use crate::types::{ Annotated, Empty, Error, ErrorKind, FromValue, Meta, Object, ProcessingAction, ProcessingResult, Value, }; +use crate::user_agent::RawUserAgentInfo; pub mod breakdowns; mod contexts; @@ -568,7 +568,7 @@ fn is_security_report(event: &Event) -> bool { fn normalize_security_report( event: &mut Event, client_ip: Option<&IpAddr>, - user_agent: Option<&str>, + user_agent: &RawUserAgentInfo<&str>, ) { if !is_security_report(event) { // This event is not a security report, exit here. @@ -582,23 +582,16 @@ fn normalize_security_report( user.ip_address = Annotated::new(client_ip.to_owned()); } - if let Some(client) = user_agent { - let request = event + if !user_agent.is_empty() { + let headers = event .request .value_mut() - .get_or_insert_with(Request::default); - - let headers = request + .get_or_insert_with(Request::default) .headers .value_mut() .get_or_insert_with(Headers::default); - if !headers.contains("User-Agent") { - headers.insert( - HeaderName::new("User-Agent"), - Annotated::new(HeaderValue::new(client)), - ); - } + user_agent.populate_event_headers(headers); } } @@ -669,7 +662,7 @@ fn normalize_logentry(logentry: &mut Annotated, _meta: &mut Meta) -> P #[derive(Default, Debug)] pub struct LightNormalizationConfig<'a> { pub client_ip: Option<&'a IpAddr>, - pub user_agent: Option<&'a str>, + pub user_agent: RawUserAgentInfo<&'a str>, pub received_at: Option>, pub max_secs_in_past: Option, pub max_secs_in_future: Option, @@ -704,7 +697,7 @@ pub fn light_normalize_event( schema::SchemaProcessor.process_event(event, meta, ProcessingState::root())?; // Process security reports first to ensure all props. - normalize_security_report(event, config.client_ip, config.user_agent); + normalize_security_report(event, config.client_ip, &config.user_agent); // Insert IP addrs before recursing, since geo lookup depends on it. normalize_ip_addresses(event, config.client_ip); @@ -1002,11 +995,12 @@ mod tests { use crate::processor::process_value; use crate::protocol::{ - ContextInner, DebugMeta, Frame, Geo, LenientString, LogEntry, PairList, RawStacktrace, + ContextInner, Csp, DebugMeta, Frame, Geo, LenientString, LogEntry, PairList, RawStacktrace, Span, SpanId, TagEntry, TraceId, Values, }; use crate::testutils::{get_path, get_value}; use crate::types::{FromValue, SerializableAnnotated}; + use crate::user_agent::ClientHints; use super::*; @@ -2436,4 +2430,58 @@ mod tests { ) "###); } + + #[test] + fn test_normalize_security_report() { + let mut event = Event { + csp: Annotated::from(Csp::default()), + ..Default::default() + }; + let ipaddr = IpAddr("213.164.1.114".to_string()); + + let client_ip = Some(&ipaddr); + let user_agent = RawUserAgentInfo::new_test_dummy(); + + // This call should fill the event headers with info from the user_agent which is + // tested below. + normalize_security_report(&mut event, client_ip, &user_agent); + + let headers = event + .request + .value_mut() + .get_or_insert_with(Request::default) + .headers + .value_mut() + .get_or_insert_with(Headers::default); + + assert_eq!( + event.user.value().unwrap().ip_address, + Annotated::from(ipaddr) + ); + assert_eq!( + headers.get_header(RawUserAgentInfo::USER_AGENT), + user_agent.user_agent + ); + assert_eq!( + headers.get_header(ClientHints::SEC_CH_UA), + user_agent.client_hints.sec_ch_ua, + ); + assert_eq!( + headers.get_header(ClientHints::SEC_CH_UA_MODEL), + user_agent.client_hints.sec_ch_ua_model, + ); + assert_eq!( + headers.get_header(ClientHints::SEC_CH_UA_PLATFORM), + user_agent.client_hints.sec_ch_ua_platform, + ); + assert_eq!( + headers.get_header(ClientHints::SEC_CH_UA_PLATFORM_VERSION), + user_agent.client_hints.sec_ch_ua_platform_version, + ); + + assert!( + std::mem::size_of_val(&ClientHints::<&str>::default()) == 64, + "If you add new fields, update the test accordingly" + ); + } } diff --git a/relay-general/src/store/normalize/user_agent.rs b/relay-general/src/store/normalize/user_agent.rs index 4d72426e46..0c9f7b6aab 100644 --- a/relay-general/src/store/normalize/user_agent.rs +++ b/relay-general/src/store/normalize/user_agent.rs @@ -31,7 +31,7 @@ pub fn normalize_user_agent(event: &mut Event) { pub fn normalize_user_agent_info_generic( contexts: &mut Contexts, platform: &Annotated, - user_agent_info: &RawUserAgentInfo, + user_agent_info: &RawUserAgentInfo<&str>, ) { if !contexts.contains_key(BrowserContext::default_key()) { if let Some(browser_context) = BrowserContext::from_hints_or_ua(user_agent_info) { diff --git a/relay-general/src/user_agent.rs b/relay-general/src/user_agent.rs index 2d128d8a0c..9d849a7cb0 100644 --- a/relay-general/src/user_agent.rs +++ b/relay-general/src/user_agent.rs @@ -7,9 +7,10 @@ //! to your consumer. use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use uaparser::{Parser, UserAgentParser}; -use crate::protocol::{Headers, Request}; +use crate::protocol::{HeaderName, HeaderValue, Headers, Request}; use crate::types::Annotated; #[doc(inline)] @@ -83,51 +84,168 @@ pub fn parse_os(user_agent: &str) -> OS { /// /// Useful for the scenarios where you will use either information from client hints if it exists, /// and if not fall back to user agent string. -#[derive(Default, Debug)] -pub struct RawUserAgentInfo<'a> { +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct RawUserAgentInfo> { /// The "old style" of a single UA string. - pub user_agent: Option<&'a str>, + pub user_agent: Option, /// User-Agent client hints. - pub client_hints: ClientHints<'a>, + pub client_hints: ClientHints, +} + +impl + Default> RawUserAgentInfo { + /// Checks if key matches a user agent header, in which case it sets the value accordingly. + /// TODO(tor): make it generic over different header types. + pub fn set_ua_field_from_header(&mut self, key: &str, value: Option) { + match key.to_lowercase().as_str() { + "user-agent" => self.user_agent = value, + + "sec-ch-ua" => self.client_hints.sec_ch_ua = value, + "sec-ch-ua-model" => self.client_hints.sec_ch_ua_model = value, + "sec-ch-ua-platform" => self.client_hints.sec_ch_ua_platform = value, + "sec-ch-ua-platform-version" => { + self.client_hints.sec_ch_ua_platform_version = value; + } + _ => {} + } + } + + /// Convert user-agent info to HTTP headers as stored in the `Request` interface. + /// + /// This function does not overwrite any pre-existing headers. + pub fn populate_event_headers(&self, headers: &mut Headers) { + let mut insert_header = |key: &str, val: Option<&S>| { + if let Some(val) = val { + if !headers.contains(key) { + headers.insert(HeaderName::new(key), Annotated::new(HeaderValue::new(val))); + } + } + }; + + insert_header(RawUserAgentInfo::USER_AGENT, self.user_agent.as_ref()); + insert_header( + ClientHints::SEC_CH_UA_PLATFORM, + self.client_hints.sec_ch_ua_platform.as_ref(), + ); + insert_header( + ClientHints::SEC_CH_UA_PLATFORM_VERSION, + self.client_hints.sec_ch_ua_platform_version.as_ref(), + ); + insert_header(ClientHints::SEC_CH_UA, self.client_hints.sec_ch_ua.as_ref()); + insert_header( + ClientHints::SEC_CH_UA_MODEL, + self.client_hints.sec_ch_ua_model.as_ref(), + ); + } + + pub fn is_empty(&self) -> bool { + self.user_agent.is_none() && self.client_hints.is_empty() + } +} + +impl RawUserAgentInfo { + pub const USER_AGENT: &str = "User-Agent"; + + pub fn as_deref(&self) -> RawUserAgentInfo<&str> { + RawUserAgentInfo::<&str> { + user_agent: self.user_agent.as_deref(), + client_hints: self.client_hints.as_deref(), + } + } +} + +impl<'a> RawUserAgentInfo<&'a str> { + pub fn from_headers(headers: &'a Headers) -> Self { + let mut contexts: RawUserAgentInfo<&str> = Self::default(); + + for item in headers.iter() { + if let Some((ref o_k, ref v)) = item.value() { + if let Some(k) = o_k.as_str() { + contexts.set_ua_field_from_header(k, v.as_str()); + } + } + } + contexts + } } /// The client hint variable names mirror the name of the "SEC-CH" headers, see /// '' -#[derive(Default, Debug)] -pub struct ClientHints<'a> { +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ClientHints> { /// The client's OS, e.g. macos, android... - pub sec_ch_ua_platform: Option<&'a str>, + pub sec_ch_ua_platform: Option, /// The version number of the client's OS. - pub sec_ch_ua_platform_version: Option<&'a str>, + pub sec_ch_ua_platform_version: Option, /// Name of the client's web browser and its version. - pub sec_ch_ua: Option<&'a str>, + pub sec_ch_ua: Option, /// Device model, e.g. samsung galaxy 3. - pub sec_ch_ua_model: Option<&'a str>, + pub sec_ch_ua_model: Option, } -impl<'a> RawUserAgentInfo<'a> { - pub fn from_headers(headers: &'a Headers) -> Self { - let mut contexts = Self::default(); +impl + Default> ClientHints { + /// Checks every field of a passed-in ClientHints instance if it contains a value, and if it does, + /// copy it to self. + pub fn copy_from(&mut self, other: ClientHints) { + if other.sec_ch_ua_platform_version.is_some() { + self.sec_ch_ua_platform_version = other.sec_ch_ua_platform_version; + } + if other.sec_ch_ua_platform.is_some() { + self.sec_ch_ua_platform = other.sec_ch_ua_platform; + } + if other.sec_ch_ua_model.is_some() { + self.sec_ch_ua_model = other.sec_ch_ua_model; + } + if other.sec_ch_ua.is_some() { + self.sec_ch_ua = other.sec_ch_ua; + } + } - for item in headers.iter() { - if let Some((ref o_k, ref v)) = item.value() { - if let Some(k) = o_k.as_str() { - match k.to_lowercase().as_str() { - "user-agent" => contexts.user_agent = v.as_str(), - - "sec-ch-ua" => contexts.client_hints.sec_ch_ua = v.as_str(), - "sec-ch-ua-model" => contexts.client_hints.sec_ch_ua_model = v.as_str(), - "sec-ch-ua-platform" => { - contexts.client_hints.sec_ch_ua_platform = v.as_str() - } - "sec-ch-ua-platform-version" => { - contexts.client_hints.sec_ch_ua_platform_version = v.as_str() - } - _ => {} - } + /// Checks if every field is of value None. + pub fn is_empty(&self) -> bool { + self.sec_ch_ua_platform.is_none() + && self.sec_ch_ua_platform_version.is_none() + && self.sec_ch_ua.is_none() + && self.sec_ch_ua_model.is_none() + } +} + +impl ClientHints { + pub const SEC_CH_UA_PLATFORM: &str = "SEC-CH-UA-Platform"; + pub const SEC_CH_UA_PLATFORM_VERSION: &str = "SEC-CH-UA-Platform-Version"; + pub const SEC_CH_UA: &str = "SEC-CH-UA"; + pub const SEC_CH_UA_MODEL: &str = "SEC-CH-UA-Model"; + + pub fn as_deref(&self) -> ClientHints<&str> { + ClientHints::<&str> { + sec_ch_ua_platform: self.sec_ch_ua_platform.as_deref(), + sec_ch_ua_platform_version: self.sec_ch_ua_platform_version.as_deref(), + sec_ch_ua: self.sec_ch_ua.as_deref(), + sec_ch_ua_model: self.sec_ch_ua_model.as_deref(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl RawUserAgentInfo<&str> { + pub fn new_test_dummy() -> Self { + Self { + user_agent: Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0"), + client_hints: ClientHints { + sec_ch_ua_platform: Some("macOS"), + sec_ch_ua_platform_version: Some("13.2.0"), + sec_ch_ua: Some(r#""Chromium";v="110", "Not A(Brand";v="24", "Google Chrome";v="110""#), + sec_ch_ua_model: Some("some model"), } + } } - contexts + } + + #[test] + fn test_default_empty() { + assert!(RawUserAgentInfo::<&str>::default().is_empty()); } } diff --git a/relay-server/src/actors/processor.rs b/relay-server/src/actors/processor.rs index db6bd99fa9..3a9920f6a9 100644 --- a/relay-server/src/actors/processor.rs +++ b/relay-server/src/actors/processor.rs @@ -1,4 +1,5 @@ use bytes::Bytes; +use relay_general::user_agent::RawUserAgentInfo; use relay_replays::recording::RecordingScrubber; use std::collections::BTreeMap; use std::convert::TryFrom; @@ -1095,7 +1096,6 @@ impl EnvelopeProcessorService { let context = &state.envelope_context; let meta = state.envelope.meta().clone(); let client_addr = meta.client_addr(); - let user_agent = meta.user_agent(); let event_id = state.envelope.event_id(); let limit = self.config.max_replay_size(); @@ -1108,7 +1108,12 @@ impl EnvelopeProcessorService { let mut scrubber = RecordingScrubber::new(limit, config.pii_config.as_ref(), datascrubbing_config); - state.envelope.retain_items(|item| match item.ty() { + let user_agent = &RawUserAgentInfo { + user_agent: meta.user_agent(), + client_hints: meta.client_hints().as_deref(), + }; + + state.envelope.retain_items(move |item| match item.ty() { ItemType::ReplayEvent => { if !replays_enabled { return false; @@ -1214,7 +1219,7 @@ impl EnvelopeProcessorService { payload: &Bytes, config: &ProjectConfig, client_ip: Option, - user_agent: Option<&str>, + user_agent: &RawUserAgentInfo<&str>, ) -> Result, ReplayError> { let mut replay = Annotated::::from_json_bytes(payload).map_err(ReplayError::CouldNotParse)?; @@ -1836,6 +1841,7 @@ impl EnvelopeProcessorService { breakdowns: project_state.config.breakdowns_v2.clone(), span_attributes: project_state.config.span_attributes.clone(), client_sample_rate: envelope.dsc().and_then(|ctx| ctx.sample_rate), + client_hints: envelope.meta().client_hints().to_owned(), }; let mut store_processor = StoreProcessor::new(store_config, self.geoip_lookup.as_ref()); @@ -2132,10 +2138,15 @@ impl EnvelopeProcessorService { &self, state: &mut ProcessEnvelopeState, ) -> Result<(), ProcessingError> { - let client_ipaddr = state.envelope.meta().client_addr().map(IpAddr::from); + let request_meta = state.envelope.meta(); + let client_ipaddr = request_meta.client_addr().map(IpAddr::from); + let config = LightNormalizationConfig { client_ip: client_ipaddr.as_ref(), - user_agent: state.envelope.meta().user_agent(), + user_agent: RawUserAgentInfo { + user_agent: request_meta.user_agent(), + client_hints: request_meta.client_hints().as_deref(), + }, received_at: Some(state.envelope_context.received_at()), max_secs_in_past: Some(self.config.max_secs_in_past()), max_secs_in_future: Some(self.config.max_secs_in_future()), diff --git a/relay-server/src/extractors/request_meta.rs b/relay-server/src/extractors/request_meta.rs index e3a7f0f7fe..6896aa15cc 100644 --- a/relay-server/src/extractors/request_meta.rs +++ b/relay-server/src/extractors/request_meta.rs @@ -7,6 +7,7 @@ use actix::ResponseFuture; use actix_web::http::header; use actix_web::{FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError}; use futures01::{future, Future}; +use relay_general::user_agent::{ClientHints, RawUserAgentInfo}; use serde::{Deserialize, Serialize}; use url::Url; @@ -161,7 +162,7 @@ fn make_false() -> bool { } /// Request information for sentry ingest data, such as events, envelopes or metrics. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RequestMeta { /// The DSN describing the target of this envelope. dsn: D, @@ -190,6 +191,9 @@ pub struct RequestMeta { #[serde(default, skip_serializing_if = "Option::is_none")] user_agent: Option, + #[serde(default, skip_serializing_if = "ClientHints::is_empty")] + client_hints: ClientHints, + /// A flag that indicates that project options caching should be bypassed. #[serde(default = "make_false", skip_serializing_if = "is_false")] no_cache: bool, @@ -256,6 +260,10 @@ impl RequestMeta { self.user_agent.as_deref() } + pub fn client_hints(&self) -> &ClientHints { + &self.client_hints + } + /// Indicates that caches should be bypassed. pub fn no_cache(&self) -> bool { self.no_cache @@ -280,22 +288,7 @@ impl RequestMeta { user_agent: Some(crate::constants::SERVER.to_owned()), no_cache: false, start_time: Instant::now(), - } - } - - #[cfg(test)] - // TODO: Remove Dsn here? - pub fn new(dsn: relay_common::Dsn) -> Self { - RequestMeta { - dsn: PartialDsn::from_dsn(dsn).expect("invalid DSN"), - client: Some("sentry/client".to_string()), - version: 7, - origin: Some("http://origin/".parse().unwrap()), - remote_addr: Some("192.168.0.1".parse().unwrap()), - forwarded_for: String::new(), - user_agent: Some("sentry/agent".to_string()), - no_cache: false, - start_time: Instant::now(), + client_hints: ClientHints::default(), } } @@ -363,6 +356,12 @@ pub type PartialMeta = RequestMeta>; impl PartialMeta { /// Extracts header information except for auth info. fn from_headers(request: &HttpRequest) -> Self { + let mut ua = RawUserAgentInfo::default(); + + for (key, value) in request.headers() { + ua.set_ua_field_from_header(key.as_str(), value.to_str().ok().map(str::to_string)); + } + RequestMeta { dsn: None, version: default_version(), @@ -371,13 +370,10 @@ impl PartialMeta { .or_else(|| parse_header_url(request, header::REFERER)), remote_addr: request.peer_addr().map(|peer| peer.ip()), forwarded_for: ForwardedFor::from(request).into_inner(), - user_agent: request - .headers() - .get(header::USER_AGENT) - .and_then(|h| h.to_str().ok()) - .map(str::to_owned), + user_agent: ua.user_agent, no_cache: false, start_time: StartTime::extract(request).into_inner(), + client_hints: ua.client_hints, } } @@ -413,6 +409,9 @@ impl PartialMeta { if self.user_agent.is_some() { complete.user_agent = self.user_agent; } + + complete.client_hints.copy_from(self.client_hints); + if self.no_cache { complete.no_cache = true; } @@ -523,6 +522,7 @@ impl FromRequest for RequestMeta { user_agent: partial_meta.user_agent, no_cache: key_flags.contains(&"no-cache"), start_time: partial_meta.start_time, + client_hints: partial_meta.client_hints, }) } } @@ -571,3 +571,71 @@ impl FromRequest for EnvelopeMeta { Ok(Box::new(future)) } } + +#[cfg(test)] +mod tests { + use super::*; + + impl RequestMeta { + // TODO: Remove Dsn here? + pub fn new(dsn: relay_common::Dsn) -> Self { + Self { + dsn: PartialDsn::from_dsn(dsn).expect("invalid DSN"), + client: Some("sentry/client".to_string()), + version: 7, + origin: Some("http://origin/".parse().unwrap()), + remote_addr: Some("192.168.0.1".parse().unwrap()), + forwarded_for: String::new(), + user_agent: Some("sentry/agent".to_string()), + no_cache: false, + start_time: Instant::now(), + client_hints: ClientHints::default(), + } + } + } + + #[test] + fn test_request_meta_roundtrip() { + let json = r#"{ + "dsn": "https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42", + "client": "sentry-client", + "version": 7, + "origin": "http://origin/", + "remote_addr": "192.168.0.1", + "forwarded_for": "8.8.8.8", + "user_agent": "0x8000", + "no_cache": false, + "client_hints": { + "sec_ch_ua_platform": "macOS", + "sec_ch_ua_platform_version": "13.1.0", + "sec_ch_ua": "\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"" + } + }"#; + + let mut deserialized: RequestMeta = serde_json::from_str(json).unwrap(); + + let reqmeta = RequestMeta { + dsn: PartialDsn::from_str("https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42") + .unwrap(), + client: Some("sentry-client".to_owned()), + version: 7, + origin: Some(Url::parse("http://origin/").unwrap()), + remote_addr: Some(IpAddr::from_str("192.168.0.1").unwrap()), + forwarded_for: "8.8.8.8".to_string(), + user_agent: Some("0x8000".to_string()), + no_cache: false, + start_time: Instant::now(), + client_hints: ClientHints { + sec_ch_ua_platform: Some("macOS".to_owned()), + sec_ch_ua_platform_version: Some("13.1.0".to_owned()), + sec_ch_ua: Some( + "\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\"" + .to_owned(), + ), + sec_ch_ua_model: None, + }, + }; + deserialized.start_time = reqmeta.start_time; + assert_eq!(deserialized, reqmeta); + } +}