feat(eap): Implement gen_ai attribute transformations#6201
feat(eap): Implement gen_ai attribute transformations#6201constantinius wants to merge 7 commits into
Conversation
Implement the two attribute transformations introduced in sentry-conventions PR #465: `gen_ai_request_messages_to_input_messages` and `gen_ai_response_to_output_messages`. These go beyond simple key renaming — they reshape attribute values to match the new schema. `gen_ai.request.messages` with a `content` field on each message is converted to `gen_ai.input.messages` with a `parts` array. `gen_ai.response.text` (plain string, JSON string array, or objects with content) and `gen_ai.response.tool_calls` are combined into a single `gen_ai.output.messages` assistant message with typed parts. The transformations run inside `normalize_ai` as part of AI span normalization (processing mode only). Attributes with the new `"transform"` deprecation status produce `WriteBehavior::CurrentName` so `normalize_attribute_names` leaves them alone — the dedicated transformation code handles the full move-and-reshape. The `gen_ai.request.messages`, `gen_ai.response.text`, and `gen_ai.response.tool_calls` legacy aliases are removed from SpanV1 SpanData so these deprecated keys survive into SpanV2 attributes where the transformation can process them. Key changes: - Add `Transform` variant to `DeprecationStatus` in the build script, producing `WriteBehavior::CurrentName` instead of `NewName` - New `gen_ai_transform` module with the two transformation functions - `normalize_ai` calls `transform_gen_ai` as its first step - Remove legacy aliases for the three deprecated attributes from SpanData - Update sentry-conventions submodule to PR #465 branch - 21 unit tests, 4 new integration tests covering SpanV1 and SpanV2
Clippy lint fix for the Transform arm in format_write_behavior.
The legacy standalone spans and transaction extraction paths convert SpanV1 to SpanV2 but skip normalize_ai. With the legacy aliases removed, deprecated keys like gen_ai.response.text survive untransformed. Call transform_gen_ai directly after span_v1_to_span_v2 on both paths so the deprecated attributes get reshaped regardless of pipeline. Also fix clippy collapsible_if lints in gen_ai_transform.
When gen_ai.response.tool_calls is not valid JSON (e.g., "some_tool_calls"), the transformation cannot parse it, so the deprecated key stays as-is. Update the test expectation to match this behavior.
| let mut span_v2 = span.map_value(|span| relay_spans::span_v1_to_span_v2(span, true)); | ||
| if let Some(span_v2) = span_v2.value_mut() | ||
| && let Some(attributes) = span_v2.attributes.value_mut() | ||
| { | ||
| eap::transform_gen_ai(attributes); | ||
| } |
There was a problem hiding this comment.
Gonna have to talk to the team about this, I understand the desire of adding it in here. As it stands right now, the right approach is to implement the normalization independent of the span format and then add it to the proper normalization paths.
Solutions which come to mind:
- Add an abstraction (maybe something like
Getterjust attribute based) which can be backed by a V1 and V2 span - Introduce a proper "post transaction" normalization, and figure out what the implications of this are
There was a problem hiding this comment.
@constantinius will get back to you, this shouldn't be something you have to figure out and do, this is more a general platform problem.
- Always remove deprecated keys even when canonical key already exists or when no parts could be extracted from the input values - Remove transform_gen_ai calls from legacy_spans/store.rs and transactions/spans.rs — transformation only runs in the EAP pipeline via normalize_ai; other paths are a platform concern - Use module import instead of super:: reference in ai.rs - Narrow visibility of transform_gen_ai to pub(crate)
| attributes.remove(GEN_AI__RESPONSE__TOOL_CALLS); | ||
|
|
||
| if parts.is_empty() { | ||
| return; |
There was a problem hiding this comment.
Response attrs dropped when empty
High Severity
When gen_ai.response.text and/or gen_ai.response.tool_calls are present but parsing yields no output parts (e.g. non-JSON tool calls like some_tool_calls, or JSON text with no usable content), the code still removes both deprecated keys and writes nothing to gen_ai.output.messages, so response data is lost instead of being moved or preserved like invalid gen_ai.request.messages.
Reviewed by Cursor Bugbot for commit aa94a95. Configure here.
| /// The output messages from the model call. | ||
| #[metastructure( | ||
| field = "gen_ai.output.messages", | ||
| legacy_alias = "gen_ai.response.tool_calls", | ||
| legacy_alias = "ai.response.toolCalls", | ||
| legacy_alias = "ai.tool_calls", | ||
| legacy_alias = "gen_ai.response.text", | ||
| legacy_alias = "ai.response.text", | ||
| legacy_alias = "ai.responses" | ||
| )] |
There was a problem hiding this comment.
Bug: Removing legacy gen_ai aliases from SpanData causes silent data loss in non-EAP ingestion paths (legacy spans, transactions) that don't use the new normalize_ai transformation.
Severity: HIGH
Suggested Fix
Reinstate the legacy aliases in SpanData to ensure backward compatibility for ingestion paths that do not use the EAP normalization pipeline. Alternatively, modify the legacy span and transaction extraction paths to also call the normalize_ai function to handle the attribute transformation.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: relay-event-schema/src/protocol/span.rs#L569-L576
Potential issue: The removal of legacy aliases for `gen_ai.request.messages`,
`gen_ai.response.text`, and `gen_ai.response.tool_calls` from the `SpanData` protocol
definition will cause data loss. Span ingestion paths for legacy standalone spans and
transaction extraction rely on these aliases to convert SpanV1 data to SpanV2
attributes. These paths do not use the new `normalize_ai` transformation logic. Without
the aliases, when `span_v1_to_span_v2` is called, the deprecated attributes are not
mapped to their canonical names and are silently dropped.
Also affects:
relay-server/src/processing/legacy_spans/store.rs:28relay-server/src/processing/transactions/spans.rs:118
| if let Some(ref raw) = tool_calls_raw { | ||
| tool_calls_to_parts(raw, &mut parts); | ||
| } | ||
|
|
||
| // Always clean up deprecated keys, even if no parts could be extracted. | ||
| attributes.remove(GEN_AI__RESPONSE__TEXT); | ||
| attributes.remove(GEN_AI__RESPONSE__TOOL_CALLS); | ||
|
|
There was a problem hiding this comment.
Bug: Non-JSON data in gen_ai.response.tool_calls is silently dropped because the attribute is removed after a failed parse, and no canonical key is written.
Severity: MEDIUM
Suggested Fix
Modify the handling of gen_ai.response.tool_calls to match gen_ai.request.messages. If serde_json::from_str fails, preserve the data by renaming the attribute to a canonical key instead of removing it. This prevents silent data loss for non-JSON values.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: relay-event-normalization/src/eap/gen_ai_transform.rs#L131-L138
Potential issue: In the `transform_gen_ai` function, if the `gen_ai.response.tool_calls`
attribute contains a string that is not valid JSON, the `tool_calls_to_parts` function
will fail to parse it and return early. The logic then unconditionally removes the
`gen_ai.response.tool_calls` attribute. Because parsing failed, no new canonical
attribute (`gen_ai.output.messages`) is written, resulting in silent data loss. This
behavior is inconsistent with the handling for `gen_ai.request.messages`, which
preserves non-JSON data by renaming the key.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f1a76fe. Configure here.
| return; | ||
| } | ||
|
|
||
| gen_ai_transform::transform_gen_ai(attributes); |
There was a problem hiding this comment.
Transaction spans skip gen_ai transform
High Severity
transform_gen_ai runs only from span V2 processing (normalize_ai in the spans pipeline). Transaction-extracted spans are converted with span_v1_to_span_v2 and stored without that step. Removing SpanData legacy aliases for gen_ai.request.messages, gen_ai.response.text, and gen_ai.response.tool_calls stops mapping those keys to canonical fields, so transaction AI spans can keep deprecated names and miss reshaped gen_ai.input.messages / gen_ai.output.messages.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit f1a76fe. Configure here.
|
@constantinius talked with the team, we'll look into dealing with the transaction normalization case in #6216 - Since you already removed the code for it, is this normalization required to run on transaction spans? So to get you unblocked I would propose we merge this change for the span streaming pipeline and we as a team take over the transaction change, if it is necessary. Also please remove the redundant information from the PR description, changed files and tests I can tell by the diff, even the functional changes I can see in code, the important information in the description and later commit is the motivation and the effect not the functional changes, which are already present in code. |


Summary
Implements the two gen_ai attribute transformations introduced in sentry-conventions PR #465:
gen_ai_request_messages_to_input_messages— transformsgen_ai.request.messages(withcontentfield per message) intogen_ai.input.messages(withpartsarray)gen_ai_response_to_output_messages— combinesgen_ai.response.textandgen_ai.response.tool_callsinto a singlegen_ai.output.messagesassistant message with typed partsThese go beyond simple key renaming — they reshape attribute values to match the new schema.
How it works
Attributes with
_status: "transform"in sentry-conventions produceWriteBehavior::CurrentName, sonormalize_attribute_namesleaves them alone. The dedicated transformation code in the newgen_ai_transformmodule handles the full move-and-reshape, called fromnormalize_aias part of AI span normalization (processing mode only).The
gen_ai.request.messages,gen_ai.response.text, andgen_ai.response.tool_callslegacy aliases are removed from SpanV1SpanDataso these deprecated keys survive into SpanV2 attributes where the transformation can process them.gen_ai.request.messages→gen_ai.input.messagesFor each message object, the
contentfield is converted to apartsarray:"hello"→[{"type": "text", "content": "hello"}]textcopied tocontenton each part if missinggen_ai.response.text+gen_ai.response.tool_calls→gen_ai.output.messagesgen_ai.response.texthandles multiple formats:content, JSON array of objects withcontentTool calls get
"type": "tool_call"added. Both are combined into one assistant message:[{"role": "assistant", "parts": [...]}].Changes
relay-conventions/build/attributes.rs— addTransformvariant toDeprecationStatus, producingWriteBehavior::CurrentNamerelay-event-normalization/src/eap/gen_ai_transform.rs— new module with transformation logicrelay-event-normalization/src/eap/ai.rs—normalize_aicallstransform_gen_aias its first steprelay-event-schema/src/protocol/span.rs— remove legacy aliases for the three deprecated attributesrelay-conventions/sentry-conventions— update submodule to PR fix(server): Allow multipart requests without trailing newline #465 branchTests
test_ai_spans_example_transactionexpectationsContributes to TET-2587