Skip to content

Commit 4d72142

Browse files
mjqclaude
andcommitted
feat(event-normalization): Add sentry.category normalization for V2 spans
Adds `normalize_span_category` function to the EAP normalization pipeline that writes `sentry.category` into span attributes. The category is derived using the same logic as V1 span processing: - From `sentry.op` via `span_op_to_category` (now public) - From span attributes (db.system.name, http.request.method, etc.) The function is called after `normalize_sentry_op` to ensure the op is available for category derivation. The category attribute is used by Sentry's Insights modules to filter spans. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5353c59 commit 4d72142

File tree

6 files changed

+364
-3
lines changed

6 files changed

+364
-3
lines changed

relay-conventions/src/consts.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ convention_attributes!(
6161
PLATFORM => "sentry.platform",
6262
PROFILE_ID => "sentry.profile_id",
6363
RELEASE => "sentry.release",
64+
RESOURCE_RENDER_BLOCKING_STATUS => "resource.render_blocking_status",
6465
RPC_GRPC_STATUS_CODE => "rpc.grpc.status_code",
6566
RPC_SERVICE => "rpc.service",
6667
SEGMENT_ID => "sentry.segment.id",
@@ -72,6 +73,7 @@ convention_attributes!(
7273
SERVER_ADDRESS => "server.address",
7374
SPAN_KIND => "sentry.kind",
7475
STATUS_MESSAGE => "sentry.status.message",
76+
UI_COMPONENT_NAME => "ui.component_name",
7577
URL_FULL => "url.full",
7678
URL_PATH => "url.path",
7779
URL_SCHEME => "url.scheme",

relay-event-normalization/src/eap/mod.rs

Lines changed: 354 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use relay_spans::derive_op_for_v2_span;
1717
use crate::span::TABLE_NAME_REGEX;
1818
use crate::span::description::{scrub_db_query, scrub_http};
1919
use crate::span::tag_extraction::{
20-
domain_from_scrubbed_http, domain_from_server_address, sql_action_from_query,
21-
sql_tables_from_query,
20+
domain_from_scrubbed_http, domain_from_server_address, span_op_to_category,
21+
sql_action_from_query, sql_tables_from_query,
2222
};
2323
use crate::{ClientHints, FromUserAgentInfo as _, RawUserAgentInfo};
2424

@@ -41,6 +41,71 @@ pub fn normalize_sentry_op(attributes: &mut Annotated<Attributes>) {
4141
attrs.insert_if_missing(OP, || inferred_op);
4242
}
4343

44+
/// Infers the sentry.category attribute and inserts it into `attributes` if not
45+
/// already set. The category is derived from the span operation or other span
46+
/// attributes.
47+
pub fn normalize_span_category(attributes: &mut Annotated<Attributes>) {
48+
// TODO(mjq): Define these constants in sentry-conventions.
49+
const SENTRY_CATEGORY: &str = "sentry.category";
50+
const AUTO_UI_BROWSER_METRICS: &str = "auto.ui.browser.metrics";
51+
52+
// Clients can explicitly set the category.
53+
if attributes
54+
.value()
55+
.is_some_and(|attrs| attrs.contains_key(SENTRY_CATEGORY))
56+
{
57+
return;
58+
}
59+
60+
let Some(attributes_val) = attributes.value() else {
61+
return;
62+
};
63+
64+
// Try to derive category from sentry.op.
65+
if let Some(op_value) = attributes_val.get_value(OP)
66+
&& let Some(op_str) = op_value.as_str()
67+
{
68+
let op_lowercase = op_str.to_lowercase();
69+
if let Some(category) = span_op_to_category(&op_lowercase) {
70+
let attrs = attributes.get_or_insert_with(Default::default);
71+
attrs.insert(SENTRY_CATEGORY, category.to_owned());
72+
return;
73+
}
74+
}
75+
76+
fn attribute_is_set(attributes: &Attributes, key: &str) -> bool {
77+
attributes
78+
.get_value(key)
79+
.and_then(|v| v.as_str())
80+
.is_some_and(|s| !s.is_empty())
81+
}
82+
83+
// Without an op, rely on attributes typically found only on spans of the given category.
84+
let category = if attribute_is_set(attributes_val, DB_SYSTEM_NAME) {
85+
Some("db")
86+
} else if attribute_is_set(attributes_val, HTTP_REQUEST_METHOD) {
87+
Some("http")
88+
} else if attribute_is_set(attributes_val, UI_COMPONENT_NAME) {
89+
Some("ui")
90+
} else if attribute_is_set(attributes_val, RESOURCE_RENDER_BLOCKING_STATUS) {
91+
Some("resource")
92+
} else if attributes_val
93+
.get_value(ORIGIN)
94+
.and_then(|v| v.as_str())
95+
.is_some_and(|v| v == AUTO_UI_BROWSER_METRICS)
96+
{
97+
Some("browser")
98+
} else {
99+
None
100+
};
101+
102+
// Write the derived category to attributes
103+
if let Some(category) = category {
104+
let attrs = attributes.get_or_insert_with(Default::default);
105+
attrs.insert(SENTRY_CATEGORY, category.to_owned());
106+
}
107+
}
108+
44109
/// Normalizes/validates all attribute types.
45110
///
46111
/// Removes and marks all attributes with an error for which the specified [`AttributeType`]
@@ -1723,4 +1788,291 @@ mod tests {
17231788
}
17241789
"#);
17251790
}
1791+
1792+
#[test]
1793+
fn test_normalize_span_category_explicit() {
1794+
// Category is already explicitly set, should not be overwritten
1795+
let mut attributes = Annotated::<Attributes>::from_json(
1796+
r#"{
1797+
"sentry.category": {
1798+
"type": "string",
1799+
"value": "custom"
1800+
},
1801+
"sentry.op": {
1802+
"type": "string",
1803+
"value": "db.query"
1804+
}
1805+
}"#,
1806+
)
1807+
.unwrap();
1808+
1809+
normalize_span_category(&mut attributes);
1810+
1811+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1812+
{
1813+
"sentry.category": {
1814+
"type": "string",
1815+
"value": "custom"
1816+
},
1817+
"sentry.op": {
1818+
"type": "string",
1819+
"value": "db.query"
1820+
}
1821+
}
1822+
"#);
1823+
}
1824+
1825+
#[test]
1826+
fn test_normalize_span_category_from_op_db() {
1827+
let mut attributes = Annotated::<Attributes>::from_json(
1828+
r#"{
1829+
"sentry.op": {
1830+
"type": "string",
1831+
"value": "db.query"
1832+
}
1833+
}"#,
1834+
)
1835+
.unwrap();
1836+
1837+
normalize_span_category(&mut attributes);
1838+
1839+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1840+
{
1841+
"sentry.category": {
1842+
"type": "string",
1843+
"value": "db"
1844+
},
1845+
"sentry.op": {
1846+
"type": "string",
1847+
"value": "db.query"
1848+
}
1849+
}
1850+
"#);
1851+
}
1852+
1853+
#[test]
1854+
fn test_normalize_span_category_from_op_http() {
1855+
let mut attributes = Annotated::<Attributes>::from_json(
1856+
r#"{
1857+
"sentry.op": {
1858+
"type": "string",
1859+
"value": "http.client"
1860+
}
1861+
}"#,
1862+
)
1863+
.unwrap();
1864+
1865+
normalize_span_category(&mut attributes);
1866+
1867+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1868+
{
1869+
"sentry.category": {
1870+
"type": "string",
1871+
"value": "http"
1872+
},
1873+
"sentry.op": {
1874+
"type": "string",
1875+
"value": "http.client"
1876+
}
1877+
}
1878+
"#);
1879+
}
1880+
1881+
#[test]
1882+
fn test_normalize_span_category_from_op_ui_framework() {
1883+
let mut attributes = Annotated::<Attributes>::from_json(
1884+
r#"{
1885+
"sentry.op": {
1886+
"type": "string",
1887+
"value": "ui.react.render"
1888+
}
1889+
}"#,
1890+
)
1891+
.unwrap();
1892+
1893+
normalize_span_category(&mut attributes);
1894+
1895+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1896+
{
1897+
"sentry.category": {
1898+
"type": "string",
1899+
"value": "ui.react"
1900+
},
1901+
"sentry.op": {
1902+
"type": "string",
1903+
"value": "ui.react.render"
1904+
}
1905+
}
1906+
"#);
1907+
}
1908+
1909+
#[test]
1910+
fn test_normalize_span_category_from_db_system() {
1911+
// Category derived from db.system.name when no op
1912+
let mut attributes = Annotated::<Attributes>::from_json(
1913+
r#"{
1914+
"db.system.name": {
1915+
"type": "string",
1916+
"value": "mongodb"
1917+
}
1918+
}"#,
1919+
)
1920+
.unwrap();
1921+
1922+
normalize_span_category(&mut attributes);
1923+
1924+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1925+
{
1926+
"db.system.name": {
1927+
"type": "string",
1928+
"value": "mongodb"
1929+
},
1930+
"sentry.category": {
1931+
"type": "string",
1932+
"value": "db"
1933+
}
1934+
}
1935+
"#);
1936+
}
1937+
1938+
#[test]
1939+
fn test_normalize_span_category_from_http_method() {
1940+
// Category derived from http.request.method when no op or db
1941+
let mut attributes = Annotated::<Attributes>::from_json(
1942+
r#"{
1943+
"http.request.method": {
1944+
"type": "string",
1945+
"value": "GET"
1946+
}
1947+
}"#,
1948+
)
1949+
.unwrap();
1950+
1951+
normalize_span_category(&mut attributes);
1952+
1953+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1954+
{
1955+
"http.request.method": {
1956+
"type": "string",
1957+
"value": "GET"
1958+
},
1959+
"sentry.category": {
1960+
"type": "string",
1961+
"value": "http"
1962+
}
1963+
}
1964+
"#);
1965+
}
1966+
1967+
#[test]
1968+
fn test_normalize_span_category_from_ui_component() {
1969+
// Category derived from ui.component_name
1970+
let mut attributes = Annotated::<Attributes>::from_json(
1971+
r#"{
1972+
"ui.component_name": {
1973+
"type": "string",
1974+
"value": "MyComponent"
1975+
}
1976+
}"#,
1977+
)
1978+
.unwrap();
1979+
1980+
normalize_span_category(&mut attributes);
1981+
1982+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
1983+
{
1984+
"sentry.category": {
1985+
"type": "string",
1986+
"value": "ui"
1987+
},
1988+
"ui.component_name": {
1989+
"type": "string",
1990+
"value": "MyComponent"
1991+
}
1992+
}
1993+
"#);
1994+
}
1995+
1996+
#[test]
1997+
fn test_normalize_span_category_from_resource() {
1998+
// Category derived from resource.render_blocking_status
1999+
let mut attributes = Annotated::<Attributes>::from_json(
2000+
r#"{
2001+
"resource.render_blocking_status": {
2002+
"type": "string",
2003+
"value": "blocking"
2004+
}
2005+
}"#,
2006+
)
2007+
.unwrap();
2008+
2009+
normalize_span_category(&mut attributes);
2010+
2011+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2012+
{
2013+
"resource.render_blocking_status": {
2014+
"type": "string",
2015+
"value": "blocking"
2016+
},
2017+
"sentry.category": {
2018+
"type": "string",
2019+
"value": "resource"
2020+
}
2021+
}
2022+
"#);
2023+
}
2024+
2025+
#[test]
2026+
fn test_normalize_span_category_from_browser_origin() {
2027+
// Category derived from sentry.origin with browser metrics value
2028+
let mut attributes = Annotated::<Attributes>::from_json(
2029+
r#"{
2030+
"sentry.origin": {
2031+
"type": "string",
2032+
"value": "auto.ui.browser.metrics"
2033+
}
2034+
}"#,
2035+
)
2036+
.unwrap();
2037+
2038+
normalize_span_category(&mut attributes);
2039+
2040+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2041+
{
2042+
"sentry.category": {
2043+
"type": "string",
2044+
"value": "browser"
2045+
},
2046+
"sentry.origin": {
2047+
"type": "string",
2048+
"value": "auto.ui.browser.metrics"
2049+
}
2050+
}
2051+
"#);
2052+
}
2053+
2054+
#[test]
2055+
fn test_normalize_span_category_no_match() {
2056+
// No category derived when no relevant attributes are present
2057+
let mut attributes = Annotated::<Attributes>::from_json(
2058+
r#"{
2059+
"some.other.attribute": {
2060+
"type": "string",
2061+
"value": "value"
2062+
}
2063+
}"#,
2064+
)
2065+
.unwrap();
2066+
2067+
normalize_span_category(&mut attributes);
2068+
2069+
insta::assert_json_snapshot!(SerializableAnnotated(&attributes), @r#"
2070+
{
2071+
"some.other.attribute": {
2072+
"type": "string",
2073+
"value": "value"
2074+
}
2075+
}
2076+
"#);
2077+
}
17262078
}

relay-event-normalization/src/normalize/span/tag_extraction.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,7 @@ fn category_for_span(span: &Span) -> Option<Cow<'static, str>> {
14781478

14791479
/// Returns the category of a span from its operation. The mapping is available in:
14801480
/// <https://develop.sentry.dev/sdk/performance/span-operations/>
1481-
fn span_op_to_category(op: &str) -> Option<&str> {
1481+
pub fn span_op_to_category(op: &str) -> Option<&str> {
14821482
let mut it = op.split('.'); // e.g. "ui.react.render"
14831483
match (it.next(), it.next()) {
14841484
// Known categories with prefixes:

0 commit comments

Comments
 (0)