relay_event_normalization/eap/
mod.rs

1//! Event normalization and processing for attribute (EAP) based payloads.
2//!
3//! A central place for all modifications/normalizations for attributes.
4
5use 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
12/// Normalizes/validates all attribute types.
13///
14/// Removes and marks all attributes with an error for which the specified [`AttributeType`]
15/// does not match the value.
16pub 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            // Note: currently the mapping to Kafka requires that invalid or unknown combinations
38            // of types and values are removed from the mapping.
39            //
40            // Usually Relay would only modify the offending values, but for now, until there
41            // is better support in the pipeline here, we need to remove the entire attribute.
42            (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
61/// Adds the `received` time to the attributes.
62pub 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
73/// Normalizes the user agent/client information into [`Attributes`].
74///
75/// Does not modify the attributes if there is already browser information present,
76/// to preserve original values.
77pub 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
102/// Normalizes the user's geographical information into [`Attributes`].
103///
104/// Does not modify the attributes if there is already user geo information present,
105/// to preserve original values.
106pub 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}