@@ -17,8 +17,8 @@ use relay_spans::derive_op_for_v2_span;
1717use crate :: span:: TABLE_NAME_REGEX ;
1818use crate :: span:: description:: { scrub_db_query, scrub_http} ;
1919use 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} ;
2323use 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}
0 commit comments