Skip to content

Commit afe5771

Browse files
mjqclaude
andauthored
feat(spans): Add sentry.category normalization for V2 spans (#5533)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent b553822 commit afe5771

File tree

8 files changed

+360
-4
lines changed

8 files changed

+360
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
**Features**:
1010

1111
- Updates `rdkafka` to 2.10 which fixes some protocol incompatibilities with Kafka 4. ([#5523](https://github.com/getsentry/relay/pull/5523))
12+
- Add sentry.category normalization for V2 spans. ([#5533](https://github.com/getsentry/relay/pull/5533))
1213
- Include cache write token cost in cost calculation for gen_ai spans. ([#5530](https://github.com/getsentry/relay/pull/5530))
1314

1415
**Bug Fixes**:

relay-conventions/src/consts.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,20 @@ convention_attributes!(
6262
PLATFORM => "sentry.platform",
6363
PROFILE_ID => "sentry.profile_id",
6464
RELEASE => "sentry.release",
65+
RESOURCE_RENDER_BLOCKING_STATUS => "resource.render_blocking_status",
6566
RPC_GRPC_STATUS_CODE => "rpc.grpc.status_code",
6667
RPC_SERVICE => "rpc.service",
6768
SEGMENT_ID => "sentry.segment.id",
6869
SEGMENT_NAME => "sentry.segment.name",
6970
SENTRY_ACTION => "sentry.action",
71+
SENTRY_CATEGORY => "sentry.category",
7072
SENTRY_DOMAIN => "sentry.domain",
7173
SENTRY_GROUP => "sentry.group",
7274
SENTRY_NORMALIZED_DESCRIPTION => "sentry.normalized_description",
7375
SERVER_ADDRESS => "server.address",
7476
SPAN_KIND => "sentry.kind",
7577
STATUS_MESSAGE => "sentry.status.message",
78+
UI_COMPONENT_NAME => "ui.component_name",
7679
URL_FULL => "url.full",
7780
URL_PATH => "url.path",
7881
URL_SCHEME => "url.scheme",

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

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

0 commit comments

Comments
 (0)