1use chrono::{DateTime, Utc};
6use relay_common::time::UnixTimestamp;
7use relay_event_schema::protocol::{AttributeType, Attributes, BrowserContext, Geo};
8use relay_protocol::{Annotated, ErrorKind, Value};
9
10use crate::{ClientHints, FromUserAgentInfo as _, RawUserAgentInfo};
11
12pub fn normalize_attribute_types(attributes: &mut Annotated<Attributes>) {
17 let Some(attributes) = attributes.value_mut() else {
18 return;
19 };
20
21 let attributes = attributes.iter_mut().map(|(_, attr)| attr);
22 for attribute in attributes {
23 use AttributeType::*;
24
25 let Some(inner) = attribute.value_mut() else {
26 continue;
27 };
28
29 match (&mut inner.value.ty, &mut inner.value.value) {
30 (Annotated(Some(Boolean), _), Annotated(Some(Value::Bool(_)), _)) => (),
31 (Annotated(Some(Integer), _), Annotated(Some(Value::I64(_)), _)) => (),
32 (Annotated(Some(Integer), _), Annotated(Some(Value::U64(_)), _)) => (),
33 (Annotated(Some(Double), _), Annotated(Some(Value::I64(_)), _)) => (),
34 (Annotated(Some(Double), _), Annotated(Some(Value::U64(_)), _)) => (),
35 (Annotated(Some(Double), _), Annotated(Some(Value::F64(_)), _)) => (),
36 (Annotated(Some(String), _), Annotated(Some(Value::String(_)), _)) => (),
37 (Annotated(Some(Unknown(_)), _), _) => {
43 let original = attribute.value_mut().take();
44 attribute.meta_mut().add_error(ErrorKind::InvalidData);
45 attribute.meta_mut().set_original_value(original);
46 }
47 (Annotated(Some(_), _), Annotated(Some(_), _)) => {
48 let original = attribute.value_mut().take();
49 attribute.meta_mut().add_error(ErrorKind::InvalidData);
50 attribute.meta_mut().set_original_value(original);
51 }
52 (Annotated(None, _), _) | (_, Annotated(None, _)) => {
53 let original = attribute.value_mut().take();
54 attribute.meta_mut().add_error(ErrorKind::MissingAttribute);
55 attribute.meta_mut().set_original_value(original);
56 }
57 }
58 }
59}
60
61pub fn normalize_received(attributes: &mut Annotated<Attributes>, received: DateTime<Utc>) {
63 attributes
64 .get_or_insert_with(Default::default)
65 .insert_if_missing("sentry.observed_timestamp_nanos", || {
66 received
67 .timestamp_nanos_opt()
68 .unwrap_or_else(|| UnixTimestamp::now().as_nanos() as i64)
69 .to_string()
70 });
71}
72
73pub fn normalize_user_agent(
78 attributes: &mut Annotated<Attributes>,
79 user_agent: Option<&str>,
80 client_hints: ClientHints<&str>,
81) {
82 let attributes = attributes.get_or_insert_with(Default::default);
83
84 const BROWSER_NAME: &str = "sentry.browser.name";
85 const BROWSER_VERSION: &str = "sentry.browser.version";
86
87 if attributes.contains_key(BROWSER_NAME) || attributes.contains_key(BROWSER_VERSION) {
88 return;
89 }
90
91 let Some(context) = BrowserContext::from_hints_or_ua(&RawUserAgentInfo {
92 user_agent,
93 client_hints,
94 }) else {
95 return;
96 };
97
98 attributes.insert_if_missing(BROWSER_NAME, || context.name);
99 attributes.insert_if_missing(BROWSER_VERSION, || context.version);
100}
101
102pub fn normalize_user_geo(
107 attributes: &mut Annotated<Attributes>,
108 info: impl FnOnce() -> Option<Geo>,
109) {
110 let attributes = attributes.get_or_insert_with(Default::default);
111
112 const COUNTRY_CODE: &str = "user.geo.country_code";
113 const CITY: &str = "user.geo.city";
114 const SUBDIVISION: &str = "user.geo.subdivision";
115 const REGION: &str = "user.geo.region";
116
117 if [COUNTRY_CODE, CITY, SUBDIVISION, REGION]
118 .into_iter()
119 .any(|a| attributes.contains_key(a))
120 {
121 return;
122 }
123
124 let Some(geo) = info() else {
125 return;
126 };
127
128 attributes.insert_if_missing(COUNTRY_CODE, || geo.country_code);
129 attributes.insert_if_missing(CITY, || geo.city);
130 attributes.insert_if_missing(SUBDIVISION, || geo.subdivision);
131 attributes.insert_if_missing(REGION, || geo.region);
132}
133
134#[cfg(test)]
135mod tests {
136 use relay_protocol::SerializableAnnotated;
137
138 use super::*;
139
140 #[test]
141 fn test_normalize_received_none() {
142 let mut attributes = Default::default();
143
144 normalize_received(
145 &mut attributes,
146 DateTime::from_timestamp_nanos(1_234_201_337),
147 );
148
149 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
150 {
151 "sentry.observed_timestamp_nanos": {
152 "type": "string",
153 "value": "1234201337"
154 }
155 }
156 "#);
157 }
158
159 #[test]
160 fn test_normalize_received_existing() {
161 let mut attributes = Annotated::from_json(
162 r#"{
163 "sentry.observed_timestamp_nanos": {
164 "type": "string",
165 "value": "111222333"
166 }
167 }"#,
168 )
169 .unwrap();
170
171 normalize_received(
172 &mut attributes,
173 DateTime::from_timestamp_nanos(1_234_201_337),
174 );
175
176 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
177 {
178 "sentry.observed_timestamp_nanos": {
179 "type": "string",
180 "value": "111222333"
181 }
182 }
183 "#);
184 }
185
186 #[test]
187 fn test_process_attribute_types() {
188 let json = r#"{
189 "valid_bool": {
190 "type": "boolean",
191 "value": true
192 },
193 "valid_int_i64": {
194 "type": "integer",
195 "value": -42
196 },
197 "valid_int_u64": {
198 "type": "integer",
199 "value": 42
200 },
201 "valid_int_from_string": {
202 "type": "integer",
203 "value": "42"
204 },
205 "valid_double": {
206 "type": "double",
207 "value": 42.5
208 },
209 "double_with_i64": {
210 "type": "double",
211 "value": -42
212 },
213 "valid_double_with_u64": {
214 "type": "double",
215 "value": 42
216 },
217 "valid_string": {
218 "type": "string",
219 "value": "test"
220 },
221 "valid_string_with_other": {
222 "type": "string",
223 "value": "test",
224 "some_other_field": "some_other_value"
225 },
226 "unknown_type": {
227 "type": "custom",
228 "value": "test"
229 },
230 "invalid_int_from_invalid_string": {
231 "type": "integer",
232 "value": "abc"
233 },
234 "missing_type": {
235 "value": "value with missing type"
236 },
237 "missing_value": {
238 "type": "string"
239 }
240 }"#;
241
242 let mut attributes = Annotated::<Attributes>::from_json(json).unwrap();
243 normalize_attribute_types(&mut attributes);
244
245 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r###"
246 {
247 "double_with_i64": {
248 "type": "double",
249 "value": -42
250 },
251 "invalid_int_from_invalid_string": null,
252 "missing_type": null,
253 "missing_value": null,
254 "unknown_type": null,
255 "valid_bool": {
256 "type": "boolean",
257 "value": true
258 },
259 "valid_double": {
260 "type": "double",
261 "value": 42.5
262 },
263 "valid_double_with_u64": {
264 "type": "double",
265 "value": 42
266 },
267 "valid_int_from_string": null,
268 "valid_int_i64": {
269 "type": "integer",
270 "value": -42
271 },
272 "valid_int_u64": {
273 "type": "integer",
274 "value": 42
275 },
276 "valid_string": {
277 "type": "string",
278 "value": "test"
279 },
280 "valid_string_with_other": {
281 "type": "string",
282 "value": "test",
283 "some_other_field": "some_other_value"
284 },
285 "_meta": {
286 "invalid_int_from_invalid_string": {
287 "": {
288 "err": [
289 "invalid_data"
290 ],
291 "val": {
292 "type": "integer",
293 "value": "abc"
294 }
295 }
296 },
297 "missing_type": {
298 "": {
299 "err": [
300 "missing_attribute"
301 ],
302 "val": {
303 "type": null,
304 "value": "value with missing type"
305 }
306 }
307 },
308 "missing_value": {
309 "": {
310 "err": [
311 "missing_attribute"
312 ],
313 "val": {
314 "type": "string",
315 "value": null
316 }
317 }
318 },
319 "unknown_type": {
320 "": {
321 "err": [
322 "invalid_data"
323 ],
324 "val": {
325 "type": "custom",
326 "value": "test"
327 }
328 }
329 },
330 "valid_int_from_string": {
331 "": {
332 "err": [
333 "invalid_data"
334 ],
335 "val": {
336 "type": "integer",
337 "value": "42"
338 }
339 }
340 }
341 }
342 }
343 "###);
344 }
345
346 #[test]
347 fn test_normalize_user_agent_none() {
348 let mut attributes = Default::default();
349 normalize_user_agent(
350 &mut attributes,
351 Some(
352 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
353 ),
354 ClientHints::default(),
355 );
356
357 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
358 {
359 "sentry.browser.name": {
360 "type": "string",
361 "value": "Chrome"
362 },
363 "sentry.browser.version": {
364 "type": "string",
365 "value": "131.0.0"
366 }
367 }
368 "#);
369 }
370
371 #[test]
372 fn test_normalize_user_agent_existing() {
373 let mut attributes = Annotated::from_json(
374 r#"{
375 "sentry.browser.name": {
376 "type": "string",
377 "value": "Very Special"
378 },
379 "sentry.browser.version": {
380 "type": "string",
381 "value": "13.3.7"
382 }
383 }"#,
384 )
385 .unwrap();
386
387 normalize_user_agent(
388 &mut attributes,
389 Some(
390 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
391 ),
392 ClientHints::default(),
393 );
394
395 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
396 {
397 "sentry.browser.name": {
398 "type": "string",
399 "value": "Very Special"
400 },
401 "sentry.browser.version": {
402 "type": "string",
403 "value": "13.3.7"
404 }
405 }
406 "#,
407 );
408 }
409
410 #[test]
411 fn test_normalize_user_geo_none() {
412 let mut attributes = Default::default();
413
414 normalize_user_geo(&mut attributes, || {
415 Some(Geo {
416 country_code: "XY".to_owned().into(),
417 city: "Foo Hausen".to_owned().into(),
418 subdivision: Annotated::empty(),
419 region: "Illu".to_owned().into(),
420 other: Default::default(),
421 })
422 });
423
424 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
425 {
426 "user.geo.city": {
427 "type": "string",
428 "value": "Foo Hausen"
429 },
430 "user.geo.country_code": {
431 "type": "string",
432 "value": "XY"
433 },
434 "user.geo.region": {
435 "type": "string",
436 "value": "Illu"
437 }
438 }
439 "#);
440 }
441
442 #[test]
443 fn test_normalize_user_geo_existing() {
444 let mut attributes = Annotated::from_json(
445 r#"{
446 "user.geo.city": {
447 "type": "string",
448 "value": "Foo Hausen"
449 }
450 }"#,
451 )
452 .unwrap();
453
454 normalize_user_geo(&mut attributes, || unreachable!());
455
456 insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
457 {
458 "user.geo.city": {
459 "type": "string",
460 "value": "Foo Hausen"
461 }
462 }
463 "#,
464 );
465 }
466}