@@ -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,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