Skip to content

Commit 1bdefc8

Browse files
authored
feat(spans): Infer descriptions via sentry-conventions (#6093)
1 parent 207939a commit 1bdefc8

9 files changed

Lines changed: 225 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
**Features**:
6+
7+
- Infer span descriptions via `sentry-conventions`. ([#6093](https://github.com/getsentry/relay/pull/6093))
8+
59
**Bug Fixes**:
610

711
- Wider type support for OTel log bodies. ([#6106](https://github.com/getsentry/relay/pull/6106))

relay-conventions/build/build.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod attributes;
2+
mod description;
23
mod measurements;
34
mod name;
5+
mod template;
46

57
use std::collections::BTreeMap;
68
use std::env;
@@ -11,6 +13,7 @@ use std::path::{Path, PathBuf};
1113
use walkdir::WalkDir;
1214

1315
const ATTRIBUTE_DIR: &str = "sentry-conventions/model/attributes";
16+
const DESCRIPTION_DIR: &str = "sentry-conventions/model/description";
1417
const MEASUREMENT_DIR: &str = "sentry-conventions/model/measurements";
1518
const NAME_DIR: &str = "sentry-conventions/model/name";
1619

@@ -20,6 +23,7 @@ fn main() {
2023
write_attribute_rs(&crate_dir);
2124
write_measurement_rs(&crate_dir);
2225
write_name_rs(&crate_dir);
26+
write_description_rs(&crate_dir);
2327

2428
// Ideally this would only run when compiling for tests, but #[cfg(test)] doesn't seem to work
2529
// here.
@@ -128,6 +132,29 @@ fn write_name_rs(crate_dir: &Path) {
128132
writeln!(&mut out_file, "{}", output).unwrap();
129133
}
130134

135+
fn write_description_rs(crate_dir: &Path) {
136+
let descriptions = WalkDir::new(crate_dir.join(DESCRIPTION_DIR))
137+
.into_iter()
138+
.flat_map(|file| {
139+
let file = file.unwrap();
140+
if file.file_type().is_file()
141+
&& let Some(ext) = file.path().extension()
142+
&& ext.to_str() == Some("json")
143+
{
144+
let contents = std::fs::read_to_string(file.path()).unwrap();
145+
Some(serde_json::from_str::<description::Description>(&contents).unwrap())
146+
} else {
147+
None
148+
}
149+
});
150+
151+
let out_path = Path::new(&env::var("OUT_DIR").unwrap()).join("description_fn.rs");
152+
let mut out_file = BufWriter::new(File::create(&out_path).unwrap());
153+
let output = description::description_file_output(descriptions);
154+
155+
writeln!(&mut out_file, "{}", output).unwrap();
156+
}
157+
131158
fn write_measurement_rs(crate_dir: &Path) {
132159
use measurements::{
133160
Measurement, format_constant, measurement_attribute_pair, write_replacement_fn,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
use proc_macro2::TokenStream;
2+
use quote::quote;
3+
use serde::Deserialize;
4+
5+
use crate::template::{TemplatePart, parse_template_into_parts};
6+
7+
#[derive(Debug, Clone, Deserialize)]
8+
pub struct Operation {
9+
pub ops: Vec<String>,
10+
pub templates: Vec<String>,
11+
}
12+
13+
#[derive(Debug, Clone, Deserialize)]
14+
pub struct Description {
15+
pub operations: Vec<Operation>,
16+
}
17+
18+
/// Returns a `TokenStream` representing Rust code defining a `description_for_op_and_attributes` method.
19+
/// This method constructs a description for the given op and attributes based on the span description
20+
/// conventions defined in the `sentry-conventions` repository.
21+
///
22+
/// Some clippy lints are explicitly allowed, when fixing them would complicate the codegen.
23+
pub fn description_file_output(descriptions: impl Iterator<Item = Description>) -> TokenStream {
24+
let operations = descriptions.flat_map(|description| description.operations);
25+
26+
let match_arms = operations.map(|Operation { ops, templates }| {
27+
let (templates_with_attributes, literal_template) = match templates.split_last() {
28+
Some((last, init)) if !last.contains("{{")=> (init, Some(last.as_str())),
29+
_ => (&templates[..], None),
30+
};
31+
32+
let conditional_attribute_blocks = templates_with_attributes.iter().map(|template| {
33+
let parts = parse_template_into_parts(template);
34+
if !parts.iter().any(|part| matches!(part, TemplatePart::Attribute(_, _))) {
35+
panic!("templates before the final template must contain attributes (bad template: {})", template);
36+
}
37+
38+
// First, each attribute becomes a let clause for our if block.
39+
let if_clauses = parts.iter().flat_map(|part| {
40+
if let TemplatePart::Attribute(name, ident) = part {
41+
Some(quote! {
42+
let Some(#ident @ (Val::String(_) | Val::Bool(_) | Val::U64(_) | Val::I64(_) | Val::F64(_))) = attributes.get_value(#name)
43+
})
44+
} else {
45+
None
46+
}
47+
});
48+
49+
// Then, construct the format string and argument list for a `format!` call to produce the name.
50+
let format_string = parts.iter().map(|part| match part {
51+
TemplatePart::Literal(l) => *l,
52+
TemplatePart::Attribute(_, _) => "{}",
53+
}).collect::<String>();
54+
let format_args = parts.iter().flat_map(|part| {
55+
if let TemplatePart::Attribute(_, ident) = part {
56+
Some(quote! { DisplayVal(#ident) })
57+
} else {
58+
None
59+
}
60+
});
61+
62+
Some(quote! {
63+
if #(#if_clauses)&&* {
64+
return Some(format!(#format_string, #(#format_args),*));
65+
};
66+
})
67+
});
68+
69+
let literal_name_fallback = match literal_template {
70+
Some(literal_template) => quote! { Some(#literal_template.to_owned()) },
71+
None => quote! { None },
72+
};
73+
74+
// Assemble the match arm, with `ops` forming the match clause and the match body checking
75+
// each template in turn before falling back to a literal (zero-attribute) template.
76+
quote! {
77+
#(#ops)|* => {
78+
#(#conditional_attribute_blocks)*
79+
#literal_name_fallback
80+
}
81+
}
82+
});
83+
84+
quote! {
85+
use relay_protocol::{Getter, Val};
86+
use std::fmt;
87+
use std::fmt::Display;
88+
89+
pub fn description_for_op_and_attributes(op: &str, attributes: &impl Getter) -> Option<String> {
90+
match op {
91+
#(#match_arms)*
92+
_ => None
93+
}
94+
}
95+
96+
struct DisplayVal<'a>(Val<'a>);
97+
98+
impl Display for DisplayVal<'_> {
99+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100+
match self.0 {
101+
Val::Bool(b) => write!(f, "{b}"),
102+
Val::I64(i) => write!(f, "{i}"),
103+
Val::U64(u) => write!(f, "{u}"),
104+
Val::F64(fl) => write!(f, "{fl}"),
105+
Val::String(s) => f.write_str(s),
106+
Val::HexId(_) | Val::Array(_) | Val::Object(_) => Ok(()),
107+
}
108+
}
109+
}
110+
}
111+
}

relay-conventions/build/name.rs

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
use pest::Parser;
2-
use proc_macro2::{Ident, TokenStream};
3-
use quote::{format_ident, quote};
1+
use proc_macro2::TokenStream;
2+
use quote::quote;
43
use serde::Deserialize;
54

5+
use crate::template::{TemplatePart, parse_template_into_parts};
6+
67
#[derive(Debug, Clone, Deserialize)]
78
pub struct Operation {
89
pub ops: Vec<String>,
@@ -116,41 +117,3 @@ pub fn name_file_output(names: impl Iterator<Item = Name>) -> TokenStream {
116117
}
117118
}
118119
}
119-
120-
enum TemplatePart<'a> {
121-
Literal(&'a str),
122-
Attribute(&'a str, Ident),
123-
}
124-
125-
fn parse_template_into_parts(template: &'_ str) -> Vec<TemplatePart<'_>> {
126-
let Ok(mut parsed) = TemplateParser::parse(Rule::root, template) else {
127-
// This panic (at build time) will make it obvious if the sentry-conventions submodule ever
128-
// contains an invalid template.
129-
panic!(
130-
"sentry_conventions contained unparseable template \"{}\"",
131-
template
132-
);
133-
};
134-
let root = parsed.next().unwrap();
135-
root.into_inner()
136-
.enumerate()
137-
.filter_map(|(i, part)| {
138-
Some(match part.as_rule() {
139-
Rule::text => TemplatePart::Literal(part.as_str()),
140-
Rule::attribute_name => {
141-
TemplatePart::Attribute(part.as_str(), format_ident!("attribute_{}", i))
142-
}
143-
Rule::EOI => return None,
144-
Rule::root | Rule::attribute => unreachable!(),
145-
})
146-
})
147-
.collect()
148-
}
149-
150-
mod parser {
151-
#[derive(pest_derive::Parser)]
152-
#[grammar = "../build/name_template.pest"]
153-
pub struct TemplateParser;
154-
}
155-
156-
use self::parser::{Rule, TemplateParser};
File renamed without changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
use pest::Parser;
2+
use proc_macro2::Ident;
3+
use quote::format_ident;
4+
5+
pub enum TemplatePart<'a> {
6+
Literal(&'a str),
7+
Attribute(&'a str, Ident),
8+
}
9+
10+
pub fn parse_template_into_parts(template: &'_ str) -> Vec<TemplatePart<'_>> {
11+
let Ok(mut parsed) = TemplateParser::parse(Rule::root, template) else {
12+
// This panic (at build time) will make it obvious if the sentry-conventions submodule ever
13+
// contains an invalid template.
14+
panic!(
15+
"sentry_conventions contained unparseable template \"{}\"",
16+
template
17+
);
18+
};
19+
let root = parsed.next().unwrap();
20+
root.into_inner()
21+
.enumerate()
22+
.filter_map(|(i, part)| {
23+
Some(match part.as_rule() {
24+
Rule::text => TemplatePart::Literal(part.as_str()),
25+
Rule::attribute_name => {
26+
TemplatePart::Attribute(part.as_str(), format_ident!("attribute_{}", i))
27+
}
28+
Rule::EOI => return None,
29+
Rule::root | Rule::attribute => unreachable!(),
30+
})
31+
})
32+
.collect()
33+
}
34+
35+
mod parser {
36+
#[derive(pest_derive::Parser)]
37+
#[grammar = "../build/name_description_template.pest"]
38+
pub struct TemplateParser;
39+
}
40+
41+
use self::parser::{Rule, TemplateParser};

relay-conventions/src/lib.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
//!
5454
//! ### I want to reference an attribute in Relay but it's not defined in `sentry-conventions`, what should I do?
5555
//! **Always** define it in `sentry-conventions` before using it in Relay. This makes sure we have proper
56+
57+
use std::fmt;
5658
pub mod attributes {
5759
//! Attribute constant definitions.
5860
#![allow(rustdoc::bare_urls)]
@@ -79,9 +81,16 @@ pub mod interpolate {
7981
include!(concat!(env!("OUT_DIR"), "/interpolation_fns.rs"));
8082
}
8183

84+
pub mod name {
85+
include!(concat!(env!("OUT_DIR"), "/name_fn.rs"));
86+
}
87+
88+
pub mod description {
89+
include!(concat!(env!("OUT_DIR"), "/description_fn.rs"));
90+
}
91+
8292
include!(concat!(env!("OUT_DIR"), "/attribute_map.rs"));
8393
include!(concat!(env!("OUT_DIR"), "/canonical_fn.rs"));
84-
include!(concat!(env!("OUT_DIR"), "/name_fn.rs"));
8594
include!(concat!(env!("OUT_DIR"), "/measurement_replacement_fn.rs"));
8695

8796
/// Whether an attribute should be PII-strippable/should be subject to datascrubbers

relay-spans/src/description.rs

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,44 @@
11
use relay_conventions::attributes::*;
2+
use relay_conventions::description::description_for_op_and_attributes;
23
use relay_event_schema::protocol::Attributes;
3-
use relay_protocol::{Annotated, Value};
4+
use relay_protocol::{Annotated, Getter, Val};
45

56
/// Derives a description for a V2 span, based on its name
67
/// and attributes.
78
///
8-
/// For now, this tries the following steps, in order:
9-
/// - returns the span's name if its [`SENTRY__ORIGIN`] attribute is `"manual"`
10-
/// - returns the span's [`DB__QUERY__TEXT`] attribute if it exists
11-
/// - returns a combination of the span's [`HTTP__REQUEST__METHOD`] and
12-
/// [`URL__FULL`] attributes, if they both exists.
13-
///
14-
/// In the future, this logic will be partly moved to and extended in `sentry-conventions`.
9+
/// For now, this attempts to return the following values, in order:
10+
/// - the span's name if its [`SENTRY__ORIGIN`] attribute is `"manual"`
11+
/// - a name constructed following the rules defined in sentry-conventions
12+
/// - `None`
1513
pub fn derive_description_for_v2_span(
1614
attributes: &Attributes,
1715
name: &Annotated<String>,
1816
) -> Option<String> {
19-
if attributes
17+
let origin = attributes
2018
.get_value(SENTRY__ORIGIN)
21-
.and_then(|o| o.as_str())
22-
== Some("manual")
23-
{
24-
return name.value().cloned();
25-
}
19+
.and_then(|o| o.as_str());
2620

27-
if let Some(&Value::String(db_query)) = attributes.get_value(DB__QUERY__TEXT).as_ref() {
28-
return Some(db_query.clone());
29-
}
21+
let name = name.as_str();
3022

31-
if let Some(&Value::String(method)) = attributes.get_value(HTTP__REQUEST__METHOD).as_ref()
32-
&& let Some(&Value::String(url)) = attributes.get_value(URL__FULL).as_ref()
23+
if let Some(name) = name
24+
&& origin == Some("manual")
3325
{
34-
return Some(format!("{method} {url}"));
26+
return Some(name.to_owned());
3527
}
3628

37-
None
29+
let op = attributes.get_value(SENTRY__OP)?.as_str()?;
30+
31+
description_for_op_and_attributes(op, &AttributeGetter(attributes))
32+
}
33+
34+
/// A custom getter for [`Attributes`] which only resolves values based on the attribute name.
35+
///
36+
/// This [`Getter`] does not implement nested traversals, which is the behaviour required for
37+
/// [`description_for_op_and_attributes`].
38+
struct AttributeGetter<'a>(&'a Attributes);
39+
40+
impl<'a> Getter for AttributeGetter<'a> {
41+
fn get_value(&self, path: &str) -> Option<Val<'_>> {
42+
self.0.get_value(path).map(|value| value.into())
43+
}
3844
}

relay-spans/src/name.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use relay_conventions::attributes::{SENTRY__DESCRIPTION, SENTRY__OP, SENTRY__ORIGIN};
2-
use relay_conventions::name_for_op_and_attributes;
2+
use relay_conventions::name::name_for_op_and_attributes;
33
use relay_event_schema::protocol::Attributes;
44
use relay_protocol::{Getter, Val};
55

0 commit comments

Comments
 (0)