Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
- Add option gating Snuba publishing to ingest-replay-events for Replays. ([#5088](https://github.com/getsentry/relay/pull/5088), [#5115](https://github.com/getsentry/relay/pull/5115))
- Add gen_ai_cost_total_tokens attribute and double write total tokens cost. ([#5121](https://github.com/getsentry/relay/pull/5121))
- Change mapping of incoming OTLP spans with `ERROR` status to Sentry's `internal_error` status. ([#5127](https://github.com/getsentry/relay/pull/5127))
- Add `ai_operation_type_map` to global config ([#5125](https://github.com/getsentry/relay/pull/5125))
- Add `ai_operation_type_map` to global config. ([#5125](https://github.com/getsentry/relay/pull/5125))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added dot at the end of the change log entry

- Infer `gen_ai.operation.type` from `span.op`. ([#5129](https://github.com/getsentry/relay/pull/5129))

## 25.8.0

Expand Down
5 changes: 3 additions & 2 deletions relay-cabi/src/processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ pub unsafe extern "C" fn relay_store_normalizer_normalize_event(
max_tag_value_length: usize::MAX,
span_description_rules: None,
performance_score: None,
geoip_lookup: None, // only supported in relay
ai_model_costs: None, // only supported in relay
geoip_lookup: None, // only supported in relay
ai_model_costs: None, // only supported in relay
ai_operation_type_map: None, // only supported in relay
enable_trimming: config.enable_trimming.unwrap_or_default(),
measurements: None,
normalize_spans: config.normalize_spans,
Expand Down
164 changes: 162 additions & 2 deletions relay-event-normalization/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ use relay_protocol::{
use smallvec::SmallVec;
use uuid::Uuid;

use crate::normalize::AiOperationTypeMap;
use crate::normalize::request;
use crate::span::ai::enrich_ai_span_data;
use crate::span::ai::enrich_ai_event_data;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a function rename - it was called enrich_ai_span_data but it was operating on an event that had a lot of spans.

Also a new function enrich_ai_span_data is now introduced that operates only on span, and it is called inside of enrich_ai_event_data to prevent code duplication

use crate::span::tag_extraction::extract_span_tags_from_event;
use crate::utils::{self, MAX_DURATION_MOBILE_MS, get_event_user_tag};
use crate::{
Expand Down Expand Up @@ -140,6 +141,9 @@ pub struct NormalizationConfig<'a> {
/// Configuration for calculating the cost of AI model runs
pub ai_model_costs: Option<&'a ModelCosts>,

/// Configuration for mapping AI operation types from span.op to gen_ai.operation.type
pub ai_operation_type_map: Option<&'a AiOperationTypeMap>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just like with ai_model_costs we don't have access to global config object in a code path where we would need it, so we add this map to NormalizationConfig which is accessible where we need it


/// An initialized GeoIP lookup.
pub geoip_lookup: Option<&'a GeoIpLookup>,

Expand Down Expand Up @@ -194,6 +198,7 @@ impl Default for NormalizationConfig<'_> {
performance_score: Default::default(),
geoip_lookup: Default::default(),
ai_model_costs: Default::default(),
ai_operation_type_map: Default::default(),
enable_trimming: false,
measurements: None,
normalize_spans: true,
Expand Down Expand Up @@ -323,7 +328,7 @@ fn normalize(event: &mut Event, meta: &mut Meta, config: &NormalizationConfig) {
.get_or_default::<PerformanceScoreContext>()
.score_profile_version = Annotated::new(version);
}
enrich_ai_span_data(event, config.ai_model_costs);
enrich_ai_event_data(event, config.ai_model_costs, config.ai_operation_type_map);
normalize_breakdowns(event, config.breakdowns_config); // Breakdowns are part of the metric extraction too
normalize_default_attributes(event, meta, config);
normalize_trace_context_tags(event);
Expand Down Expand Up @@ -2847,6 +2852,161 @@ mod tests {
);
}

#[test]
fn test_ai_operation_type_mapping() {
let json = r#"
{
"type": "transaction",
"transaction": "test-transaction",
"spans": [
{
"op": "gen_ai.chat",
"description": "AI chat completion",
"data": {}
},
{
"op": "gen_ai.handoff",
"description": "AI agent handoff",
"data": {}
},
{
"op": "gen_ai.unknown",
"description": "Unknown AI operation",
"data": {}
}
]
}
"#;

let mut event = Annotated::<Event>::from_json(json).unwrap();

let operation_type_map = AiOperationTypeMap {
version: 1,
operation_types: HashMap::from([
(Pattern::new("gen_ai.chat").unwrap(), "chat".to_owned()),
(
Pattern::new("gen_ai.execute_tool").unwrap(),
"execute_tool".to_owned(),
),
(
Pattern::new("gen_ai.handoff").unwrap(),
"handoff".to_owned(),
),
(
Pattern::new("gen_ai.invoke_agent").unwrap(),
"invoke_agent".to_owned(),
),
// fallback to agent
(Pattern::new("gen_ai.*").unwrap(), "agent".to_owned()),
]),
};

normalize_event(
&mut event,
&NormalizationConfig {
ai_operation_type_map: Some(&operation_type_map),
..NormalizationConfig::default()
},
);

let span_data_0 = get_value!(event.spans[0].data!);
let span_data_1 = get_value!(event.spans[1].data!);
let span_data_2 = get_value!(event.spans[2].data!);

assert_eq!(
span_data_0.gen_ai_operation_type.value(),
Some(&"chat".to_owned())
);

assert_eq!(
span_data_1.gen_ai_operation_type.value(),
Some(&"handoff".to_owned())
);

// Third span should have operation type mapped to "agent" fallback
assert_eq!(
span_data_2.gen_ai_operation_type.value(),
Some(&"agent".to_owned())
);
}

#[test]
fn test_ai_operation_type_disabled_map() {
let json = r#"
{
"type": "transaction",
"transaction": "test-transaction",
"spans": [
{
"op": "gen_ai.chat",
"description": "AI chat completion",
"data": {}
}
]
}
"#;

let mut event = Annotated::<Event>::from_json(json).unwrap();

let operation_type_map = AiOperationTypeMap {
version: 0, // Disabled version
operation_types: HashMap::from([(
Pattern::new("gen_ai.chat").unwrap(),
"chat".to_owned(),
)]),
};

normalize_event(
&mut event,
&NormalizationConfig {
ai_operation_type_map: Some(&operation_type_map),
..NormalizationConfig::default()
},
);

let span_data = get_value!(event.spans[0].data!);

// Should not set operation type when map is disabled
assert_eq!(span_data.gen_ai_operation_type.value(), None);
}

#[test]
fn test_ai_operation_type_empty_map() {
let json = r#"
{
"type": "transaction",
"transaction": "test-transaction",
"spans": [
{
"op": "gen_ai.chat",
"description": "AI chat completion",
"data": {}
}
]
}
"#;

let mut event = Annotated::<Event>::from_json(json).unwrap();

let operation_type_map = AiOperationTypeMap {
version: 1,
operation_types: HashMap::new(),
};

normalize_event(
&mut event,
&NormalizationConfig {
ai_operation_type_map: Some(&operation_type_map),
..NormalizationConfig::default()
},
);

let span_data = get_value!(event.spans[0].data!);

// Should not set operation type when map is empty
assert_eq!(span_data.gen_ai_operation_type.value(), None);
}

#[test]
fn test_apple_high_device_class() {
let mut event = Event {
Expand Down
76 changes: 73 additions & 3 deletions relay-event-normalization/src/normalize/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ pub struct ModelCostV2 {
}
/// A mapping of AI operation types from span.op to gen_ai.operation.type.
///
/// This struct uses a dictionary-based mapping structure with exact span operation keys
/// This struct uses a dictionary-based mapping structure with pattern-based span operation keys
/// and corresponding AI operation type values.
///
/// Example JSON:
Expand All @@ -322,7 +322,7 @@ pub struct AiOperationTypeMap {

/// The mappings of span.op => gen_ai.operation.type as a dictionary
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub operation_types: HashMap<String, String>,
pub operation_types: HashMap<Pattern, String>,
}

impl AiOperationTypeMap {
Expand All @@ -343,7 +343,22 @@ impl AiOperationTypeMap {
if !self.is_enabled() {
return None;
}
self.operation_types.get(span_op).map(String::as_str)

// try first direct match with span_op
if let Some(value) = self.operation_types.get(span_op) {
return Some(value.as_str());
}

// if there is not a direct match, try to find the match using a pattern
let operation_type = self.operation_types.iter().find_map(|(key, value)| {
if key.is_match(span_op) {
Some(value)
} else {
None
}
});

operation_type.map(String::as_str)
}
}
#[cfg(test)]
Expand Down Expand Up @@ -546,6 +561,61 @@ mod tests {
assert!(!deserialized.is_enabled());
}

#[test]
fn test_ai_operation_type_map_serialization() {
// Test serialization and deserialization with patterns
let mut operation_types = HashMap::new();
operation_types.insert(
Pattern::new("gen_ai.chat*").unwrap(),
"Inference".to_owned(),
);
operation_types.insert(
Pattern::new("gen_ai.execute_tool").unwrap(),
"Tool".to_owned(),
);

let original = AiOperationTypeMap {
version: 1,
operation_types,
};

let json = serde_json::to_string(&original).unwrap();
let deserialized: AiOperationTypeMap = serde_json::from_str(&json).unwrap();

assert!(deserialized.is_enabled());
assert_eq!(
deserialized.get_operation_type("gen_ai.chat.completions"),
Some("Inference")
);
assert_eq!(
deserialized.get_operation_type("gen_ai.execute_tool"),
Some("Tool")
);
assert_eq!(deserialized.get_operation_type("unknown_op"), None);
}

#[test]
fn test_ai_operation_type_map_pattern_matching() {
let mut operation_types = HashMap::new();
operation_types.insert(Pattern::new("gen_ai.*").unwrap(), "default".to_owned());
operation_types.insert(Pattern::new("gen_ai.chat").unwrap(), "chat".to_owned());

let map = AiOperationTypeMap {
version: 1,
operation_types,
};

let result = map.get_operation_type("gen_ai.chat");
assert!(Some("chat") == result);

let result = map.get_operation_type("gen_ai.chat.completions");
assert!(Some("default") == result);

assert_eq!(map.get_operation_type("gen_ai.other"), Some("default"));

assert_eq!(map.get_operation_type("other.operation"), None);
}

#[test]
fn test_merge_builtin_measurement_keys() {
let foo = BuiltinMeasurementKey::new("foo", MetricUnit::Duration(DurationUnit::Hour));
Expand Down
Loading
Loading