Skip to main content

relay_conventions/
lib.rs

1//! Relay's interface to [`sentry-conventions`](https://github.com/getsentry/sentry-conventions).
2//!
3//! This crate contains the `sentry-conventions` repository as a git submodule.
4//!
5//! # Attributes
6//!
7//! Attribute definitions in `sentry-conventions` are parsed at build time. For each attribute, this crate exposes:
8//! * A constant whose value is the attribute's key in `sentry-conventions`. This constant has a deprecation tag if
9//!   the attribute is marked as deprecated in `sentry-conventions`, leading to a compiler warning if it's used anyway.
10//! * A record of the attribute's relevant parameters (PII status, backfilling normalizing behavior). These records can
11//!   be queried using the [`attribute_info`] function.
12//!
13//! ## Attribute normalization
14//! Deprecated attributes can have their `_status` set to `null`, `"backfill"`, or `"normalize"` in `sentry-conventions`.
15//! These values affect how Relay normalizes the attribute:
16//! * `null`: The attribute is left unchanged.
17//! * `"backfill"`: The attribute's value is _copied_ to the replacement attribute, but an existing value for the
18//!   replacement attribute is not overwritten. Thus, if the replacement attribute is already present, nothing happens.
19//! * `"normalize"`: The attribute's value is _moved_ to the replacement attribute, but an existing value for the
20//!   replacement attribute is not overwritten. Thus, if the replacement attribute is already present, _the deprecated
21//!   attribute is deleted_.
22//!
23//! This normalization step runs before other steps (user agent detection, category
24//! detection, …) that depend on specific attributes. Therefore, those other normalizations only need to check the
25//! values of non-deprecated attributes.
26//!
27//! # FAQ
28//!
29//! ### I've changed something in `sentry-conventions`, how do I get Relay to pick it up?
30//! Relay parses `sentry-conventions` at compile time, so any change requires a PR to Relay and needs to be deployed.
31//!
32//! In Relay, Update the `sentry-conventions` submodule:
33//! ```bash
34//! cd relay-conventions/sentry-conventions
35//! git checkout main
36//! git pull
37//! ```
38//! Then open a PR with your change.
39//!
40//! ### Why is my deprecated attribute not being rewritten to the current name?
41//! Make sure the attribute's `deprecation["_status"]` field is set to either `"backfill"` or `"normalize"`,
42//! depending on what you want to happen (see above). If this is set correctly and it's still not working,
43//! verify the behaviour with an integration test and open an issue.
44//!
45//! ### After updating `sentry-conventions` I get a warning about a deprecated constant, what should I do?
46//! This means that Relay is reading or writing an attribute that is deprecated in the new conventions version.
47//!
48//! * If the attribute has a replacement and is set to `"backfill"` or `"normalize"`, you can just replace the constant
49//!   with the current version and normalization takes care of the rest.
50//! * If the attribute has a replacement but no `"backfill"` or `"normalize"`, consider changing that in `sentry-conventions`.
51//!   Unless there is a good reason not to, you probably want the attribute to be rewritten.
52//! * Otherwise, we may have to decide what to do on a case-by-case basis.
53//!
54//! ### I want to reference an attribute in Relay but it's not defined in `sentry-conventions`, what should I do?
55//! **Always** define it in `sentry-conventions` before using it in Relay. This makes sure we have proper
56
57use std::fmt;
58pub mod attributes {
59    //! Attribute constant definitions.
60    #![allow(rustdoc::bare_urls)]
61    #![allow(non_upper_case_globals)]
62    include!(concat!(env!("OUT_DIR"), "/attribute_consts.rs"));
63
64    mod not_yet_defined {
65        pub const SENTRY__EVENT__SERIALIZED_REQUEST: &str = "sentry.event.serialized_request";
66
67        // TODO(buenaflor): Add as sentry convention once mobile SDKs can migrate to it.
68        // Tracking issue: https://github.com/getsentry/sentry-conventions/issues/318
69        pub const APP__VITALS__START__VALUE: &str = "app.vitals.start.value";
70    }
71    pub use self::not_yet_defined::*;
72}
73
74pub mod measurements {
75    //! Measurement constant definitions.
76    #![allow(non_upper_case_globals)]
77    include!(concat!(env!("OUT_DIR"), "/measurement_consts.rs"));
78}
79
80pub mod interpolate {
81    //! Functions for interpolating attribute keys with placeholders.
82    #![allow(non_snake_case)]
83    include!(concat!(env!("OUT_DIR"), "/interpolation_fns.rs"));
84}
85
86pub mod name {
87    include!(concat!(env!("OUT_DIR"), "/name_fn.rs"));
88}
89
90pub mod description {
91    include!(concat!(env!("OUT_DIR"), "/description_fn.rs"));
92}
93
94include!(concat!(env!("OUT_DIR"), "/attribute_map.rs"));
95include!(concat!(env!("OUT_DIR"), "/canonical_fn.rs"));
96include!(concat!(env!("OUT_DIR"), "/measurement_replacement_fn.rs"));
97
98/// Whether an attribute should be scrubbed.
99#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
100pub enum ApplyScrubbing {
101    /// The attribute will be stripped by default.
102    Auto,
103    /// The attribute will only be stripped when addressed with a specific path selector, but generic
104    /// selectors such as `$string` do not apply.
105    Manual,
106    /// The attribute cannot be stripped at all.
107    Never,
108}
109
110/// The name of the replacement of a deprecated attribute.
111#[derive(Clone, Copy)]
112pub enum ReplacementName {
113    /// The replacement attribute has a fixed name,
114    /// i.e., doesn't contain a placeholder.
115    Static(&'static str),
116    /// The replacement attribute contains a placeholder.
117    ///
118    /// This means its name can't be used "as-is"; a value
119    /// has to be inserted into the placeholder. The contained
120    /// function performs this insertion.
121    Dynamic(fn(&str) -> String),
122}
123
124impl fmt::Debug for ReplacementName {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            Self::Static(arg0) => f.debug_tuple("Static").field(arg0).finish(),
128            Self::Dynamic(_) => f.debug_tuple("Dynamic").finish(),
129        }
130    }
131}
132
133/// Under which names an attribute should be saved.
134#[derive(Debug, Clone, Copy)]
135pub enum WriteBehavior {
136    /// Save the attribute under its current name.
137    ///
138    /// This is the only choice for attributes that aren't deprecated.
139    CurrentName,
140    /// Save the attribute under its replacement name instead.
141    NewName(ReplacementName),
142    /// Save the attribute under both its current name and
143    /// its replacement name.
144    BothNames(ReplacementName),
145}
146
147/// Information about an attribute, as defined in `sentry-conventions`.
148#[derive(Debug, Clone)]
149pub struct AttributeInfo {
150    /// How this attribute should be saved.
151    pub write_behavior: WriteBehavior,
152    /// Whether this attribute can contain PII.
153    pub apply_scrubbing: ApplyScrubbing,
154    /// Other attribute names that alias to this attribute.
155    pub aliases: &'static [&'static str],
156}
157
158/// Returns information about an attribute, as defined in `sentry-conventions`.
159///
160/// If the matched attribute contains a placeholder (`<key>`), the second returned
161/// value is the part of the attribute key that was inserted for the placeholder.
162pub fn attribute_info_with_fragment(key: &str) -> Option<(&'static AttributeInfo, Option<&str>)> {
163    ATTRIBUTES.find(key)
164}
165
166/// Returns information about an attribute, as defined in `sentry-conventions`.
167pub fn attribute_info(key: &str) -> Option<&'static AttributeInfo> {
168    attribute_info_with_fragment(key).map(|(info, _)| info)
169}
170
171/// Special path segment in attribute keys that matches any value.
172const PLACEHOLDER_SEGMENT: &str = "<key>";
173
174struct Node<T: 'static> {
175    info: Option<T>,
176    children: phf::Map<&'static str, Node<T>>,
177}
178
179impl<T> Node<T> {
180    fn find<'a>(&self, key: &'a str) -> Option<(&T, Option<&'a str>)> {
181        if key.is_empty() {
182            return self.info.as_ref().map(|info| (info, None));
183        }
184        let (prefix, suffix) = key.split_once('.').unwrap_or((key, ""));
185
186        // First try a literal lookup.
187        // If the prefix is `"<key>"`, we skip this and fall through
188        // to the second attempt.
189        if prefix != PLACEHOLDER_SEGMENT
190            && let Some(info) = self
191                .children
192                .get(prefix)
193                .and_then(|child| child.find(suffix))
194        {
195            return Some(info);
196        }
197
198        // If the literal lookup doesn't succeed, try a placeholder
199        // lookup and bubble up the current `prefix` if it succeeds.
200        if let Some((info, _)) = self
201            .children
202            .get(PLACEHOLDER_SEGMENT)
203            .and_then(|child| child.find(suffix))
204        {
205            return Some((info, Some(prefix)));
206        }
207        None
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use std::collections::HashMap;
214
215    use phf::phf_map;
216    use relay_protocol::{Annotated, Value};
217
218    use super::*;
219
220    #[test]
221    fn test_http_response_content_length() {
222        let info = attribute_info("http.response_content_length").unwrap();
223
224        insta::assert_debug_snapshot!(info, @r#"
225        AttributeInfo {
226            write_behavior: BothNames(
227                Static(
228                    "http.response.body.size",
229                ),
230            ),
231            apply_scrubbing: Manual,
232            aliases: [
233                "http.response.body.size",
234                "http.response.header.content-length",
235            ],
236        }
237        "#);
238    }
239
240    #[test]
241    fn test_url_path_parameter() {
242        // See https://github.com/getsentry/sentry-conventions/blob/d80504a40ba3a0a23eb746e2608425cf8d8e68bf/model/attributes/url/url__path__parameter__%5Bkey%5D.json.
243        let (info, fragment) = attribute_info_with_fragment("url.path.parameter.'id=123'").unwrap();
244        assert_eq!(fragment, Some("'id=123'"));
245
246        insta::assert_debug_snapshot!(info, @r#"
247        AttributeInfo {
248            write_behavior: CurrentName,
249            apply_scrubbing: Auto,
250            aliases: [
251                "params.<key>",
252                "url.path.params.<key>",
253            ],
254        }
255        "#);
256    }
257
258    /// Tests that `cls.source.<key>` is rewritten to `browser.web_vital.cls.source.<key>`.
259    #[test]
260    fn test_cls_source_key() {
261        let (info, fragment) = attribute_info_with_fragment("cls.source.foobar").unwrap();
262
263        let WriteBehavior::BothNames(ReplacementName::Dynamic(name_fn)) = info.write_behavior
264        else {
265            unreachable!();
266        };
267
268        assert_eq!(
269            name_fn(fragment.unwrap()),
270            "browser.web_vital.cls.source.foobar"
271        );
272    }
273
274    const ROOT: Node<u8> = Node {
275        info: None,
276        children: phf_map! {
277            "foo" => Node {
278                info: Some(1),
279                children: phf_map!{}
280            },
281            "<key>" =>  Node {
282                info: None,
283                children: phf_map!{
284                    "bar" => Node {
285                        info: Some(2),
286                        children: phf_map! {}
287                    }
288                }
289            }
290        },
291    };
292
293    #[test]
294    fn test_hypothetical() {
295        assert_eq!(ROOT.find("foo.bar"), Some((&2, Some("foo"))));
296        assert_eq!(ROOT.find("<key>.bar"), Some((&2, Some("<key>"))));
297    }
298
299    mod test_name_fn {
300        include!(concat!(env!("OUT_DIR"), "/test_name_fn.rs"));
301    }
302    use test_name_fn::name_for_op_and_attributes;
303
304    #[test]
305    fn only_literal_template() {
306        let attributes: HashMap<&str, Value> = HashMap::new();
307        assert_eq!(
308            name_for_op_and_attributes("op_with_literal_name", |name| attributes.get(name))
309                .unwrap(),
310            "literal name"
311        );
312    }
313
314    #[test]
315    fn multiple_ops_same_template() {
316        let attributes = HashMap::from([("attr1", Value::String("foo".to_owned()))]);
317        assert_eq!(
318            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
319                .unwrap(),
320            "foo"
321        );
322        assert_eq!(
323            name_for_op_and_attributes("op_with_attributes_2", |name| attributes.get(name))
324                .unwrap(),
325            "foo"
326        );
327    }
328
329    #[test]
330    fn skips_templates_when_attrs_are_missing() {
331        let attributes = HashMap::from([
332            ("attr2", Value::String("bar".to_owned())),
333            ("attr3", Value::String("baz".to_owned())),
334        ]);
335        assert_eq!(
336            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
337                .unwrap(),
338            "bar baz"
339        );
340    }
341
342    #[test]
343    fn handles_literal_prefixes_and_suffixes() {
344        let attributes = HashMap::from([("attr3", Value::String("baz".to_owned()))]);
345        assert_eq!(
346            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
347                .unwrap(),
348            "prefix baz suffix",
349        );
350    }
351
352    #[test]
353    fn considers_multiple_files() {
354        let attributes: HashMap<&str, Value> = HashMap::new();
355        assert_eq!(
356            name_for_op_and_attributes("op_in_second_name_file", |name| attributes.get(name))
357                .unwrap(),
358            "second file literal name",
359        );
360    }
361
362    #[test]
363    fn returns_none_for_unknown_ops() {
364        let attributes: HashMap<&str, Value> = HashMap::new();
365        assert!(name_for_op_and_attributes("unknown_op", |name| attributes.get(name)).is_none());
366    }
367
368    #[test]
369    fn handles_multiple_value_types() {
370        let attributes = HashMap::from([("attr1", Value::Bool(true))]);
371        assert_eq!(
372            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
373                .unwrap(),
374            "true",
375        );
376
377        let attributes = HashMap::from([("attr1", Value::I64(123))]);
378        assert_eq!(
379            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
380                .unwrap(),
381            "123",
382        );
383
384        let attributes = HashMap::from([("attr1", Value::U64(123))]);
385        assert_eq!(
386            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
387                .unwrap(),
388            "123",
389        );
390
391        let attributes = HashMap::from([("attr1", Value::F64(1.23))]);
392        assert_eq!(
393            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
394                .unwrap(),
395            "1.23",
396        );
397    }
398
399    #[test]
400    fn name_handles_arrays() {
401        let attributes = HashMap::from([
402            (
403                "attr2",
404                Value::Array(vec![
405                    Annotated::new("posts:123".into()),
406                    Annotated::new("posts:456".into()),
407                ]),
408            ),
409            ("attr3", Value::U64(789)),
410        ]);
411
412        assert_eq!(
413            name_for_op_and_attributes("op_with_attributes_1", |name| attributes.get(name))
414                .unwrap(),
415            "posts:123, posts:456 789",
416        );
417    }
418}