Skip to content

Commit

Permalink
feat(otlp): infer span category from attributes (#4509)
Browse files Browse the repository at this point in the history
Relay assigns a `category` to incoming spans based on their `op` (see
[our
docs](https://develop.sentry.dev/sdk/telemetry/traces/span-operations/#list-of-operations)
for possible category values).

Unlike Sentry's `op` field, OTel span names aren't structured and
therefore can't be used to determine the span `category`.

In order to support categorizing incoming OTel spans, update the span
categorization algorithm to:

1. Use the incoming `sentry.category` attribute directly if given. This
allows our SDKs to be explicit about the intended category and also lets
users explicitly perform categorization in case our automatic
categorization is ever incorrect.
2. Extract the category from `op`. This is what we do today, and will
keep categorization consistent for Sentry SDK clients sending spans via
transaction events now and in the future.
3. When `op` is missing (e.g. an OTLP span), infer the category based on
the span's attributes. This will categorize incoming OTel spans based on
semantic conventions.

Currently the inference only supports the five categories we use in the
transaction summary page's operation filter, which is the page the
Performance team is currently migrating to EAP. We'll expand it in the
future as we move more of the product surface area over to support
EAP/span-first/OTLP.
  • Loading branch information
mjq authored Feb 20, 2025
1 parent b2cb997 commit fddc641
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 24 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
**Features**:

- Add new `relay-threading` crate with asynchronous thread pool. ([#4500](https://github.com/getsentry/relay/pull/4500))
- Support span `category` inference from span attributes. ([#4509](https://github.com/getsentry/relay/pull/4509))

## 25.2.0

Expand Down
189 changes: 167 additions & 22 deletions relay-event-normalization/src/normalize/span/tag_extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use relay_event_schema::protocol::{
AppContext, BrowserContext, Event, Measurement, OsContext, ProfileContext, SentryTags, Span,
Timestamp, TraceContext,
};
use relay_protocol::{Annotated, Value};
use relay_protocol::{Annotated, Empty, Value};
use sqlparser::ast::Visit;
use sqlparser::ast::{ObjectName, Visitor};
use url::Url;
Expand Down Expand Up @@ -518,14 +518,10 @@ pub fn extract_tags(

span_tags.op = span_op.to_owned().into();

let category = span_op_to_category(&span_op);
if let Some(category) = category {
span_tags.category = category.to_owned().into();
}
let category = category_for_span(span);

let (scrubbed_description, parsed_sql) = scrub_span_description(span, span_allowed_hosts);

let action = match (category, span_op.as_str(), &scrubbed_description) {
let action = match (category.as_deref(), span_op.as_str(), &scrubbed_description) {
(Some("http"), _, _) => span
.data
.value()
Expand Down Expand Up @@ -558,6 +554,23 @@ pub fn extract_tags(
_ => None,
};

if category.as_deref() == Some("ai") {
if let Some(ai_pipeline_name) = span
.data
.value()
.and_then(|data| data.ai_pipeline_name.value())
.and_then(|val| val.as_str())
{
let mut ai_pipeline_group = format!("{:?}", md5::compute(ai_pipeline_name));
ai_pipeline_group.truncate(16);
span_tags.ai_pipeline_group = ai_pipeline_group.into();
}
}

if let Some(category) = category {
span_tags.category = category.into_owned().into();
}

if let Some(act) = action {
span_tags.action = act.into();
}
Expand Down Expand Up @@ -726,19 +739,6 @@ pub fn extract_tags(
span_tags.description = truncated.into();
}

if category == Some("ai") {
if let Some(ai_pipeline_name) = span
.data
.value()
.and_then(|data| data.ai_pipeline_name.value())
.and_then(|val| val.as_str())
{
let mut ai_pipeline_group = format!("{:?}", md5::compute(ai_pipeline_name));
ai_pipeline_group.truncate(16);
span_tags.ai_pipeline_group = ai_pipeline_group.into();
}
}

if span_op.starts_with("resource.") {
// TODO: Remove response size tags once product uses measurements instead.
if let Some(data) = span.data.value() {
Expand Down Expand Up @@ -1150,6 +1150,52 @@ fn extract_captured_substring<'a>(string: &'a str, pattern: &'a Lazy<Regex>) ->
None
}

fn category_for_span(span: &Span) -> Option<Cow<'static, str>> {
// Allow clients to explicitly set the category via attribute.
if let Some(Value::String(category)) = span
.data
.value()
.and_then(|v| v.other.get("sentry.category"))
.and_then(|c| c.value())
{
return Some(category.to_owned().into());
}

// If we're given an op, derive the category from that.
if let Some(unsanitized_span_op) = span.op.value() {
let span_op = unsanitized_span_op.to_lowercase();
if let Some(category) = span_op_to_category(&span_op) {
return Some(category.to_owned().into());
}
}

// Derive the category from the span's attributes.
let span_data = span.data.value()?;

fn value_is_set(value: &Annotated<Value>) -> bool {
value.value().is_some_and(|v| !v.is_empty())
}

if value_is_set(&span_data.db_system) {
Some("db".into())
} else if value_is_set(&span_data.http_request_method) {
Some("http".into())
} else if value_is_set(&span_data.ui_component_name) {
Some("ui".into())
} else if value_is_set(&span_data.resource_render_blocking_status) {
Some("resource".into())
} else if span_data
.other
.get("sentry.origin")
.and_then(|v| v.as_str())
.is_some_and(|v| v == "auto.ui.browser.metrics")
{
Some("browser".into())
} else {
None
}
}

/// Returns the category of a span from its operation. The mapping is available in:
/// <https://develop.sentry.dev/sdk/performance/span-operations/>
fn span_op_to_category(op: &str) -> Option<&str> {
Expand Down Expand Up @@ -1195,8 +1241,8 @@ fn get_event_start_type(event: &Event) -> Option<&'static str> {
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use relay_event_schema::protocol::Request;
use relay_protocol::{get_value, Getter};
use relay_event_schema::protocol::{Request, SpanData};
use relay_protocol::{get_value, Getter, Object};

use super::*;
use crate::span::description::{scrub_queries, Mode};
Expand Down Expand Up @@ -2750,4 +2796,103 @@ LIMIT 1
assert_eq!(get_value!(span.sentry_tags.thread_id!), "42",);
assert_eq!(get_value!(span.sentry_tags.thread_name!), "main",);
}

#[test]
fn span_category_from_explicit_attribute_overrides_op() {
let span = Span {
op: "app.start".to_owned().into(),
data: SpanData {
other: Object::from([(
"sentry.category".into(),
Value::String("db".into()).into(),
)]),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("db".into()));
}

#[test]
fn span_category_from_op_overrides_inference() {
let span = Span {
op: "app.start".to_owned().into(),
data: SpanData {
db_system: Value::String("postgresql".into()).into(),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("app".into()));
}

#[test]
fn infers_db_category_from_attributes() {
let span = Span {
data: SpanData {
db_system: Value::String("postgresql".into()).into(),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("db".into()));
}

#[test]
fn infers_http_category_from_attributes() {
let span = Span {
data: SpanData {
http_request_method: Value::String("POST".into()).into(),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("http".into()));
}

#[test]
fn infers_ui_category_from_attributes() {
let span = Span {
data: SpanData {
ui_component_name: Value::String("MainComponent".into()).into(),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("ui".into()));
}

#[test]
fn infers_resource_category_from_attributes() {
let span = Span {
data: SpanData {
resource_render_blocking_status: Value::String("true".into()).into(),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("resource".into()));
}

#[test]
fn infers_browser_category_from_attributes() {
let span = Span {
data: SpanData {
other: Object::from([(
"sentry.origin".into(),
Value::String("auto.ui.browser.metrics".into()).into(),
)]),
..Default::default()
}
.into(),
..Default::default()
};
assert_eq!(category_for_span(&span), Some("browser".into()));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: relay-server/src/metrics_extraction/event.rs
expression: metrics
expression: metrics.project_metrics
---
[
Bucket {
Expand Down Expand Up @@ -8749,6 +8749,7 @@ expression: metrics
],
),
tags: {
"span.category": "ui",
"span.description": "my-component-name",
"span.group": "e674f9eca1d88a4d",
"span.op": "ui.interaction.click",
Expand Down Expand Up @@ -8798,6 +8799,7 @@ expression: metrics
],
),
tags: {
"span.category": "ui",
"span.description": "my-component-name",
"span.group": "e674f9eca1d88a4d",
"span.op": "ui.interaction.click",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: relay-server/src/metrics_extraction/event.rs
expression: metrics
expression: metrics.project_metrics
---
[
Bucket {
Expand Down Expand Up @@ -8174,6 +8174,7 @@ expression: metrics
],
),
tags: {
"span.category": "ui",
"span.description": "my-component-name",
"span.group": "e674f9eca1d88a4d",
"span.op": "ui.interaction.click",
Expand Down Expand Up @@ -8223,6 +8224,7 @@ expression: metrics
],
),
tags: {
"span.category": "ui",
"span.description": "my-component-name",
"span.group": "e674f9eca1d88a4d",
"span.op": "ui.interaction.click",
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/test_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ def test_span_ingestion(
key="sentry.exclusive_time_nano",
value=AnyValue(int_value=int(duration.total_seconds() * 1e9)),
),
# In order to test `category` sentry tag inference.
KeyValue(
key="ui.component_name",
value=AnyValue(string_value="MyComponent"),
),
],
)
scope_spans = ScopeSpans(spans=[protobuf_span])
Expand Down Expand Up @@ -699,6 +704,7 @@ def test_span_ingestion(
"data": {
"browser.name": "Python Requests",
"client.address": "127.0.0.1",
"ui.component_name": "MyComponent",
"user_agent.original": "python-requests/2.32.2",
},
"description": "my 3rd protobuf OTel span",
Expand All @@ -712,6 +718,7 @@ def test_span_ingestion(
"retention_days": 90,
"sentry_tags": {
"browser.name": "Python Requests",
"category": "ui",
"op": "default",
"status": "unknown",
},
Expand Down

0 comments on commit fddc641

Please sign in to comment.