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