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        // TODO(buenaflor): Add as sentry convention once mobile SDKs can migrate to it.
66        // Tracking issue: https://github.com/getsentry/sentry-conventions/issues/318
67        pub const APP__VITALS__START__VALUE: &str = "app.vitals.start.value";
68    }
69    pub use self::not_yet_defined::*;
70}
71
72pub mod measurements {
73    //! Measurement constant definitions.
74    #![allow(non_upper_case_globals)]
75    include!(concat!(env!("OUT_DIR"), "/measurement_consts.rs"));
76}
77
78pub mod interpolate {
79    //! Functions for interpolating attribute keys with placeholders.
80    #![allow(non_snake_case)]
81    include!(concat!(env!("OUT_DIR"), "/interpolation_fns.rs"));
82}
83
84pub mod name {
85    include!(concat!(env!("OUT_DIR"), "/name_fn.rs"));
86}
87
88pub mod description {
89    include!(concat!(env!("OUT_DIR"), "/description_fn.rs"));
90}
91
92include!(concat!(env!("OUT_DIR"), "/attribute_map.rs"));
93include!(concat!(env!("OUT_DIR"), "/canonical_fn.rs"));
94include!(concat!(env!("OUT_DIR"), "/measurement_replacement_fn.rs"));
95
96/// Whether an attribute should be scrubbed.
97#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
98pub enum ApplyScrubbing {
99    /// The attribute will be stripped by default.
100    Auto,
101    /// The attribute will only be stripped when addressed with a specific path selector, but generic
102    /// selectors such as `$string` do not apply.
103    Manual,
104    /// The attribute cannot be stripped at all.
105    Never,
106}
107
108/// The name of the replacement of a deprecated attribute.
109#[derive(Clone, Copy)]
110pub enum ReplacementName {
111    /// The replacement attribute has a fixed name,
112    /// i.e., doesn't contain a placeholder.
113    Static(&'static str),
114    /// The replacement attribute contains a placeholder.
115    ///
116    /// This means its name can't be used "as-is"; a value
117    /// has to be inserted into the placeholder. The contained
118    /// function performs this insertion.
119    Dynamic(fn(&str) -> String),
120}
121
122impl fmt::Debug for ReplacementName {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::Static(arg0) => f.debug_tuple("Static").field(arg0).finish(),
126            Self::Dynamic(_) => f.debug_tuple("Dynamic").finish(),
127        }
128    }
129}
130
131/// Under which names an attribute should be saved.
132#[derive(Debug, Clone, Copy)]
133pub enum WriteBehavior {
134    /// Save the attribute under its current name.
135    ///
136    /// This is the only choice for attributes that aren't deprecated.
137    CurrentName,
138    /// Save the attribute under its replacement name instead.
139    NewName(ReplacementName),
140    /// Save the attribute under both its current name and
141    /// its replacement name.
142    BothNames(ReplacementName),
143}
144
145/// Information about an attribute, as defined in `sentry-conventions`.
146#[derive(Debug, Clone)]
147pub struct AttributeInfo {
148    /// How this attribute should be saved.
149    pub write_behavior: WriteBehavior,
150    /// Whether this attribute can contain PII.
151    pub apply_scrubbing: ApplyScrubbing,
152    /// Other attribute names that alias to this attribute.
153    pub aliases: &'static [&'static str],
154}
155
156/// Returns information about an attribute, as defined in `sentry-conventions`.
157///
158/// If the matched attribute contains a placeholder (`<key>`), the second returned
159/// value is the part of the attribute key that was inserted for the placeholder.
160pub fn attribute_info_with_fragment(key: &str) -> Option<(&'static AttributeInfo, Option<&str>)> {
161    ATTRIBUTES.find(key)
162}
163
164/// Returns information about an attribute, as defined in `sentry-conventions`.
165pub fn attribute_info(key: &str) -> Option<&'static AttributeInfo> {
166    attribute_info_with_fragment(key).map(|(info, _)| info)
167}
168
169/// Special path segment in attribute keys that matches any value.
170const PLACEHOLDER_SEGMENT: &str = "<key>";
171
172struct Node<T: 'static> {
173    info: Option<T>,
174    children: phf::Map<&'static str, Node<T>>,
175}
176
177impl<T> Node<T> {
178    fn find<'a>(&self, key: &'a str) -> Option<(&T, Option<&'a str>)> {
179        if key.is_empty() {
180            return self.info.as_ref().map(|info| (info, None));
181        }
182        let (prefix, suffix) = key.split_once('.').unwrap_or((key, ""));
183
184        // First try a literal lookup.
185        // If the prefix is `"<key>"`, we skip this and fall through
186        // to the second attempt.
187        if prefix != PLACEHOLDER_SEGMENT
188            && let Some(info) = self
189                .children
190                .get(prefix)
191                .and_then(|child| child.find(suffix))
192        {
193            return Some(info);
194        }
195
196        // If the literal lookup doesn't succeed, try a placeholder
197        // lookup and bubble up the current `prefix` if it succeeds.
198        if let Some((info, _)) = self
199            .children
200            .get(PLACEHOLDER_SEGMENT)
201            .and_then(|child| child.find(suffix))
202        {
203            return Some((info, Some(prefix)));
204        }
205        None
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use std::collections::HashMap;
212
213    use phf::phf_map;
214    use relay_protocol::{Getter, Val};
215
216    use super::*;
217
218    #[test]
219    fn test_http_response_content_length() {
220        let info = attribute_info("http.response_content_length").unwrap();
221
222        insta::assert_debug_snapshot!(info, @r#"
223        AttributeInfo {
224            write_behavior: BothNames(
225                Static(
226                    "http.response.body.size",
227                ),
228            ),
229            apply_scrubbing: Manual,
230            aliases: [
231                "http.response.body.size",
232                "http.response.header.content-length",
233            ],
234        }
235        "#);
236    }
237
238    #[test]
239    fn test_url_path_parameter() {
240        // See https://github.com/getsentry/sentry-conventions/blob/d80504a40ba3a0a23eb746e2608425cf8d8e68bf/model/attributes/url/url__path__parameter__%5Bkey%5D.json.
241        let (info, fragment) = attribute_info_with_fragment("url.path.parameter.'id=123'").unwrap();
242        assert_eq!(fragment, Some("'id=123'"));
243
244        insta::assert_debug_snapshot!(info, @r#"
245        AttributeInfo {
246            write_behavior: CurrentName,
247            apply_scrubbing: Auto,
248            aliases: [
249                "params.<key>",
250            ],
251        }
252        "#);
253    }
254
255    /// Tests that `cls.source.<key>` is rewritten to `browser.web_vital.cls.source.<key>`.
256    #[test]
257    fn test_cls_source_key() {
258        let (info, fragment) = attribute_info_with_fragment("cls.source.foobar").unwrap();
259
260        let WriteBehavior::BothNames(ReplacementName::Dynamic(name_fn)) = info.write_behavior
261        else {
262            unreachable!();
263        };
264
265        assert_eq!(
266            name_fn(fragment.unwrap()),
267            "browser.web_vital.cls.source.foobar"
268        );
269    }
270
271    const ROOT: Node<u8> = Node {
272        info: None,
273        children: phf_map! {
274            "foo" => Node {
275                info: Some(1),
276                children: phf_map!{}
277            },
278            "<key>" =>  Node {
279                info: None,
280                children: phf_map!{
281                    "bar" => Node {
282                        info: Some(2),
283                        children: phf_map! {}
284                    }
285                }
286            }
287        },
288    };
289
290    #[test]
291    fn test_hypothetical() {
292        assert_eq!(ROOT.find("foo.bar"), Some((&2, Some("foo"))));
293        assert_eq!(ROOT.find("<key>.bar"), Some((&2, Some("<key>"))));
294    }
295
296    struct GetterMap<'a>(HashMap<&'a str, Val<'a>>);
297
298    impl Getter for GetterMap<'_> {
299        fn get_value(&self, path: &str) -> Option<Val<'_>> {
300            self.0.get(path).copied()
301        }
302    }
303
304    mod test_name_fn {
305        include!(concat!(env!("OUT_DIR"), "/test_name_fn.rs"));
306    }
307    use test_name_fn::name_for_op_and_attributes;
308
309    #[test]
310    fn only_literal_template() {
311        let attributes = GetterMap(HashMap::new());
312        assert_eq!(
313            name_for_op_and_attributes("op_with_literal_name", &attributes,).unwrap(),
314            "literal name"
315        );
316    }
317
318    #[test]
319    fn multiple_ops_same_template() {
320        let attributes = GetterMap(HashMap::from([("attr1", Val::String("foo"))]));
321        assert_eq!(
322            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
323            "foo"
324        );
325        assert_eq!(
326            name_for_op_and_attributes("op_with_attributes_2", &attributes).unwrap(),
327            "foo"
328        );
329    }
330
331    #[test]
332    fn skips_templates_when_attrs_are_missing() {
333        let attributes = GetterMap(HashMap::from([
334            ("attr2", Val::String("bar")),
335            ("attr3", Val::String("baz")),
336        ]));
337        assert_eq!(
338            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
339            "bar baz"
340        );
341    }
342
343    #[test]
344    fn handles_literal_prefixes_and_suffixes() {
345        let attributes = GetterMap(HashMap::from([("attr3", Val::String("baz"))]));
346        assert_eq!(
347            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
348            "prefix baz suffix",
349        );
350    }
351
352    #[test]
353    fn considers_multiple_files() {
354        let attributes = GetterMap(HashMap::new());
355        assert_eq!(
356            name_for_op_and_attributes("op_in_second_name_file", &attributes).unwrap(),
357            "second file literal name",
358        );
359    }
360
361    #[test]
362    fn returns_none_for_unknown_ops() {
363        let attributes = GetterMap(HashMap::new());
364        assert!(name_for_op_and_attributes("unknown_op", &attributes).is_none());
365    }
366
367    #[test]
368    fn handles_multiple_value_types() {
369        let attributes = GetterMap(HashMap::from([("attr1", Val::Bool(true))]));
370        assert_eq!(
371            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
372            "true",
373        );
374
375        let attributes = GetterMap(HashMap::from([("attr1", Val::I64(123))]));
376        assert_eq!(
377            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
378            "123",
379        );
380
381        let attributes = GetterMap(HashMap::from([("attr1", Val::U64(123))]));
382        assert_eq!(
383            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
384            "123",
385        );
386
387        let attributes = GetterMap(HashMap::from([("attr1", Val::F64(1.23))]));
388        assert_eq!(
389            name_for_op_and_attributes("op_with_attributes_1", &attributes).unwrap(),
390            "1.23",
391        );
392    }
393}