From 5c3da4b500dd21b2486f9d97f8a0422c25b8e893 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Fri, 26 Jun 2026 09:20:56 +0200 Subject: [PATCH] feat(attributes): add gen ai data migrations Add migrations that normalize deprecated gen_ai request and response attributes into the newer input and output message attributes. The migrations preserve existing replacement values, remove normalized source attributes, and include coverage across JavaScript, Python, and Rust. --- .../sentry-conventions/src/attributes.ts | 15 ++ .../sentry-conventions/src/migrations.ts | 173 +++++++++++++- .../gen_ai/gen_ai__input__messages.json | 3 + .../gen_ai/gen_ai__output__messages.json | 3 + .../gen_ai/gen_ai__request__messages.json | 3 + .../gen_ai/gen_ai__response__text.json | 3 + .../gen_ai/gen_ai__response__tool_calls.json | 3 + python/src/sentry_conventions/attributes.py | 15 ++ python/src/sentry_conventions/migrations.py | 189 ++++++++++++++- python/tests/test_migrations.py | 198 ++++++++++++++- rust/src/migrations.rs | 212 +++++++++++++++- rust/tests/migrations.rs | 226 +++++++++++++++++- test/migrations.test.ts | 175 +++++++++++++- 13 files changed, 1183 insertions(+), 35 deletions(-) diff --git a/javascript/sentry-conventions/src/attributes.ts b/javascript/sentry-conventions/src/attributes.ts index db9cf1d5..6386d190 100644 --- a/javascript/sentry-conventions/src/attributes.ts +++ b/javascript/sentry-conventions/src/attributes.ts @@ -19613,6 +19613,9 @@ export const ATTRIBUTE_METADATA: Record = { visibility: 'public', example: '[{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_VSPygqKTWdrhaFErNvMV18Yl", "name": "get_weather", "arguments": {"location": "Paris"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_VSPygqKTWdrhaFErNvMV18Yl", "result": "rainy, 57°F"}]}]', + migration: { + targetOf: ['gen_ai_request_messages_to_input_messages'], + }, aliases: [AI_TEXTS], changelog: [ { version: '0.5.0', prs: [264] }, @@ -19660,6 +19663,9 @@ export const ATTRIBUTE_METADATA: Record = { visibility: 'public', example: '[{"role": "assistant", "parts": [{"type": "text", "content": "The weather in Paris is currently rainy with a temperature of 57°F."}], "finish_reason": "stop"}]', + migration: { + targetOf: ['gen_ai_response_to_output_messages'], + }, changelog: [{ version: '0.4.0', prs: [221] }], }, [GEN_AI_PIPELINE_NAME]: { @@ -19773,6 +19779,9 @@ export const ATTRIBUTE_METADATA: Record = { visibility: 'public', example: '[{"role": "system", "content": "Generate a random number."}, {"role": "user", "content": [{"text": "Generate a random number between 0 and 10.", "type": "text"}]}, {"role": "tool", "content": {"toolCallId": "1", "toolName": "Weather", "output": "rainy"}}]', + migration: { + sourceFor: ['gen_ai_request_messages_to_input_messages'], + }, deprecation: { replacement: 'gen_ai.input.messages', status: 'normalize', @@ -19940,6 +19949,9 @@ export const ATTRIBUTE_METADATA: Record = { visibility: 'public', example: '["The weather in Paris is rainy and overcast, with temperatures around 57°F", "The weather in London is sunny and warm, with temperatures around 65°F"]', + migration: { + sourceFor: ['gen_ai_response_to_output_messages'], + }, deprecation: { replacement: 'gen_ai.output.messages', status: 'normalize', @@ -20003,6 +20015,9 @@ export const ATTRIBUTE_METADATA: Record = { isInOtel: false, visibility: 'public', example: '[{"name": "get_weather", "arguments": {"location": "Paris"}}]', + migration: { + sourceFor: ['gen_ai_response_to_output_messages'], + }, deprecation: { replacement: 'gen_ai.output.messages', status: 'normalize', diff --git a/javascript/sentry-conventions/src/migrations.ts b/javascript/sentry-conventions/src/migrations.ts index 7d4f17d2..20868b61 100644 --- a/javascript/sentry-conventions/src/migrations.ts +++ b/javascript/sentry-conventions/src/migrations.ts @@ -1,7 +1,11 @@ -import { ATTRIBUTE_METADATA, type AttributeValue } from './attributes'; +import { ATTRIBUTE_METADATA, GEN_AI_INPUT_MESSAGES, GEN_AI_OUTPUT_MESSAGES, type AttributeValue } from './attributes'; export type AttributeMigrationId = string; +const GEN_AI_REQUEST_MESSAGES = 'gen_ai.request.messages'; +const GEN_AI_RESPONSE_TEXT = 'gen_ai.response.text'; +const GEN_AI_RESPONSE_TOOL_CALLS = 'gen_ai.response.tool_calls'; + type AttributeMap = Record; type AttributeMigrationFn = (attributes: AttributeMap) => AttributeValue | undefined; @@ -26,8 +30,175 @@ function defineAttributeMigration( return migrate; } +// Utilities + +function parseJson(value: AttributeValue): unknown | undefined { + if (typeof value !== 'string') { + return undefined; + } + + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function parseJsonArray(value: AttributeValue): unknown[] | undefined { + const parsed = parseJson(value); + return Array.isArray(parsed) ? parsed : undefined; +} + +function textPart(content: string): Record { + return { type: 'text', content }; +} + +function normalizeMessageContent(content: unknown): unknown[] { + if (typeof content === 'string') { + return [textPart(content)]; + } + + if (Array.isArray(content)) { + return content.map((part) => { + if (typeof part === 'string') { + return textPart(part); + } + + if (part && typeof part === 'object' && 'text' in part && !('content' in part)) { + return { ...part, content: (part as { text: unknown }).text }; + } + + return part; + }); + } + + return [content]; +} + +function migrateGenAiRequestMessagesValue(value: AttributeValue): AttributeValue { + if (typeof value === 'string' && parseJson(value) === undefined) { + return JSON.stringify([{ role: 'user', parts: [textPart(value)] }]); + } + + const messages = parseJsonArray(value); + if (!messages) { + return value; + } + + if (messages.every((message) => typeof message === 'string')) { + return JSON.stringify(messages.map((message) => ({ role: 'user', parts: [textPart(message as string)] }))); + } + + if (messages.every((message) => message && typeof message === 'object')) { + return JSON.stringify( + messages.map((message) => { + const input = message as Record; + if ('parts' in input) { + return input; + } + if ('content' in input) { + const { content, ...rest } = input; + return { ...rest, parts: normalizeMessageContent(content) }; + } + return input; + }), + ); + } + + return value; +} + +function extractResponseTextParts(value: AttributeValue | undefined): Record[] { + if (value === undefined) { + return []; + } + + const parsed = parseJson(value); + if (typeof value === 'string' && parsed === undefined) { + return [textPart(value)]; + } + + if (typeof parsed === 'string') { + return [textPart(parsed)]; + } + + if (Array.isArray(parsed)) { + return parsed.flatMap((message) => { + if (typeof message === 'string') { + return [textPart(message)]; + } + if (message && typeof message === 'object') { + const input = message as Record; + if (typeof input.content === 'string') { + return [textPart(input.content)]; + } + if (Array.isArray(input.parts)) { + return input.parts as Record[]; + } + } + return []; + }); + } + + if (parsed && typeof parsed === 'object') { + const input = parsed as Record; + if (typeof input.content === 'string') { + return [textPart(input.content)]; + } + if (Array.isArray(input.parts)) { + return input.parts as Record[]; + } + } + + return []; +} + +// Migrations + +defineAttributeMigration( + { + id: 'gen_ai_request_messages_to_input_messages', + sources: [GEN_AI_REQUEST_MESSAGES], + replacement: GEN_AI_INPUT_MESSAGES, + }, + (attributes) => { + const value = attributes[GEN_AI_REQUEST_MESSAGES]; + return value === undefined ? undefined : migrateGenAiRequestMessagesValue(value); + }, +); + +defineAttributeMigration( + { + id: 'gen_ai_response_to_output_messages', + sources: [GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS], + replacement: GEN_AI_OUTPUT_MESSAGES, + }, + (attributes) => { + const textValue = attributes[GEN_AI_RESPONSE_TEXT]; + const toolCallsValue = attributes[GEN_AI_RESPONSE_TOOL_CALLS]; + const parts: Record[] = extractResponseTextParts(textValue); + + const toolCalls = toolCallsValue === undefined ? undefined : parseJsonArray(toolCallsValue); + if (toolCalls) { + for (const toolCall of toolCalls) { + if (toolCall && typeof toolCall === 'object') { + parts.push({ ...(toolCall as Record), type: 'tool_call' }); + } + } + } + + if (parts.length > 0) { + return JSON.stringify([{ role: 'assistant', parts }]); + } + + return textValue ?? toolCallsValue; + }, +); + export const ATTRIBUTE_MIGRATIONS = attributeMigrations as readonly AttributeMigration[]; +// Public API + /** * Migrates a single deprecated attribute value when the migration only depends on that attribute. * diff --git a/model/attributes/gen_ai/gen_ai__input__messages.json b/model/attributes/gen_ai/gen_ai__input__messages.json index d1416216..fb3b774c 100644 --- a/model/attributes/gen_ai/gen_ai__input__messages.json +++ b/model/attributes/gen_ai/gen_ai__input__messages.json @@ -8,6 +8,9 @@ "is_in_otel": true, "example": "[{\"role\": \"user\", \"parts\": [{\"type\": \"text\", \"content\": \"Weather in Paris?\"}]}, {\"role\": \"assistant\", \"parts\": [{\"type\": \"tool_call\", \"id\": \"call_VSPygqKTWdrhaFErNvMV18Yl\", \"name\": \"get_weather\", \"arguments\": {\"location\": \"Paris\"}}]}, {\"role\": \"tool\", \"parts\": [{\"type\": \"tool_call_response\", \"id\": \"call_VSPygqKTWdrhaFErNvMV18Yl\", \"result\": \"rainy, 57°F\"}]}]", "alias": ["ai.texts"], + "migration": { + "target_of": ["gen_ai_request_messages_to_input_messages"] + }, "visibility": "public", "changelog": [ { diff --git a/model/attributes/gen_ai/gen_ai__output__messages.json b/model/attributes/gen_ai/gen_ai__output__messages.json index ec509c35..432f37eb 100644 --- a/model/attributes/gen_ai/gen_ai__output__messages.json +++ b/model/attributes/gen_ai/gen_ai__output__messages.json @@ -7,6 +7,9 @@ }, "is_in_otel": true, "example": "[{\"role\": \"assistant\", \"parts\": [{\"type\": \"text\", \"content\": \"The weather in Paris is currently rainy with a temperature of 57°F.\"}], \"finish_reason\": \"stop\"}]", + "migration": { + "target_of": ["gen_ai_response_to_output_messages"] + }, "visibility": "public", "changelog": [ { diff --git a/model/attributes/gen_ai/gen_ai__request__messages.json b/model/attributes/gen_ai/gen_ai__request__messages.json index cf13b82f..e87df68f 100644 --- a/model/attributes/gen_ai/gen_ai__request__messages.json +++ b/model/attributes/gen_ai/gen_ai__request__messages.json @@ -8,6 +8,9 @@ "is_in_otel": false, "example": "[{\"role\": \"system\", \"content\": \"Generate a random number.\"}, {\"role\": \"user\", \"content\": [{\"text\": \"Generate a random number between 0 and 10.\", \"type\": \"text\"}]}, {\"role\": \"tool\", \"content\": {\"toolCallId\": \"1\", \"toolName\": \"Weather\", \"output\": \"rainy\"}}]", "alias": ["ai.input_messages"], + "migration": { + "source_for": ["gen_ai_request_messages_to_input_messages"] + }, "deprecation": { "_status": "normalize", "replacement": "gen_ai.input.messages" diff --git a/model/attributes/gen_ai/gen_ai__response__text.json b/model/attributes/gen_ai/gen_ai__response__text.json index 8dd61f40..5b299a5e 100644 --- a/model/attributes/gen_ai/gen_ai__response__text.json +++ b/model/attributes/gen_ai/gen_ai__response__text.json @@ -8,6 +8,9 @@ "is_in_otel": false, "example": "[\"The weather in Paris is rainy and overcast, with temperatures around 57°F\", \"The weather in London is sunny and warm, with temperatures around 65°F\"]", "alias": [], + "migration": { + "source_for": ["gen_ai_response_to_output_messages"] + }, "deprecation": { "_status": "normalize", "replacement": "gen_ai.output.messages" diff --git a/model/attributes/gen_ai/gen_ai__response__tool_calls.json b/model/attributes/gen_ai/gen_ai__response__tool_calls.json index 9f74e981..c0ea642f 100644 --- a/model/attributes/gen_ai/gen_ai__response__tool_calls.json +++ b/model/attributes/gen_ai/gen_ai__response__tool_calls.json @@ -8,6 +8,9 @@ "is_in_otel": false, "example": "[{\"name\": \"get_weather\", \"arguments\": {\"location\": \"Paris\"}}]", "alias": [], + "migration": { + "source_for": ["gen_ai_response_to_output_messages"] + }, "deprecation": { "_status": "normalize", "replacement": "gen_ai.output.messages" diff --git a/python/src/sentry_conventions/attributes.py b/python/src/sentry_conventions/attributes.py index 4595a8e6..8076db9c 100644 --- a/python/src/sentry_conventions/attributes.py +++ b/python/src/sentry_conventions/attributes.py @@ -12332,6 +12332,9 @@ class ATTRIBUTE_NAMES(metaclass=_AttributeNamesMeta): is_in_otel=True, visibility=Visibility.PUBLIC, example='[{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_VSPygqKTWdrhaFErNvMV18Yl", "name": "get_weather", "arguments": {"location": "Paris"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_VSPygqKTWdrhaFErNvMV18Yl", "result": "rainy, 57°F"}]}]', + migration=AttributeMigrationInfo( + target_of=["gen_ai_request_messages_to_input_messages"] + ), aliases=["ai.texts"], changelog=[ ChangelogEntry(version="0.5.0", prs=[264]), @@ -12369,6 +12372,9 @@ class ATTRIBUTE_NAMES(metaclass=_AttributeNamesMeta): is_in_otel=True, visibility=Visibility.PUBLIC, example='[{"role": "assistant", "parts": [{"type": "text", "content": "The weather in Paris is currently rainy with a temperature of 57°F."}], "finish_reason": "stop"}]', + migration=AttributeMigrationInfo( + target_of=["gen_ai_response_to_output_messages"] + ), changelog=[ ChangelogEntry(version="0.4.0", prs=[221]), ], @@ -12478,6 +12484,9 @@ class ATTRIBUTE_NAMES(metaclass=_AttributeNamesMeta): is_in_otel=False, visibility=Visibility.PUBLIC, example='[{"role": "system", "content": "Generate a random number."}, {"role": "user", "content": [{"text": "Generate a random number between 0 and 10.", "type": "text"}]}, {"role": "tool", "content": {"toolCallId": "1", "toolName": "Weather", "output": "rainy"}}]', + migration=AttributeMigrationInfo( + source_for=["gen_ai_request_messages_to_input_messages"] + ), deprecation=DeprecationInfo( replacement="gen_ai.input.messages", status=DeprecationStatus.NORMALIZE ), @@ -12633,6 +12642,9 @@ class ATTRIBUTE_NAMES(metaclass=_AttributeNamesMeta): is_in_otel=False, visibility=Visibility.PUBLIC, example='["The weather in Paris is rainy and overcast, with temperatures around 57°F", "The weather in London is sunny and warm, with temperatures around 65°F"]', + migration=AttributeMigrationInfo( + source_for=["gen_ai_response_to_output_messages"] + ), deprecation=DeprecationInfo( replacement="gen_ai.output.messages", status=DeprecationStatus.NORMALIZE ), @@ -12697,6 +12709,9 @@ class ATTRIBUTE_NAMES(metaclass=_AttributeNamesMeta): is_in_otel=False, visibility=Visibility.PUBLIC, example='[{"name": "get_weather", "arguments": {"location": "Paris"}}]', + migration=AttributeMigrationInfo( + source_for=["gen_ai_response_to_output_messages"] + ), deprecation=DeprecationInfo( replacement="gen_ai.output.messages", status=DeprecationStatus.NORMALIZE ), diff --git a/python/src/sentry_conventions/migrations.py b/python/src/sentry_conventions/migrations.py index de7ecfbb..0aa9fd77 100644 --- a/python/src/sentry_conventions/migrations.py +++ b/python/src/sentry_conventions/migrations.py @@ -7,11 +7,13 @@ to ``normalize``. """ +import json from dataclasses import dataclass -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, cast from sentry_conventions.attributes import ( ATTRIBUTE_METADATA, + ATTRIBUTE_NAMES, AttributeValue, DeprecationStatus, ) @@ -19,6 +21,10 @@ AttributeMap = Dict[str, AttributeValue] AttributeMigrationFn = Callable[[AttributeMap], Optional[AttributeValue]] +GEN_AI_REQUEST_MESSAGES = "gen_ai.request.messages" +GEN_AI_RESPONSE_TEXT = "gen_ai.response.text" +GEN_AI_RESPONSE_TOOL_CALLS = "gen_ai.response.tool_calls" + @dataclass(frozen=True) class AttributeMigration: @@ -59,6 +65,170 @@ def register(function: AttributeMigrationFn) -> AttributeMigrationFn: return register +# Utilities + + +def _parse_json(value: AttributeValue) -> Optional[object]: + if not isinstance(value, str): + return None + + try: + return json.loads(value) + except ValueError: + return None + + +def _parse_json_array(value: AttributeValue) -> Optional[List[object]]: + parsed = _parse_json(value) + if isinstance(parsed, list): + return parsed + return None + + +def _text_part(content: str) -> Dict[str, object]: + return {"type": "text", "content": content} + + +def _normalize_message_content(content: object) -> List[object]: + if isinstance(content, str): + return [_text_part(content)] + + if isinstance(content, list): + parts: List[object] = [] + for part in content: + if isinstance(part, str): + parts.append(_text_part(part)) + elif isinstance(part, dict) and "text" in part and "content" not in part: + normalized = dict(part) + normalized["content"] = normalized["text"] + parts.append(normalized) + else: + parts.append(part) + return parts + + return [content] + + +def _migrate_gen_ai_request_messages_value(value: AttributeValue) -> AttributeValue: + if isinstance(value, str) and _parse_json(value) is None: + return json.dumps( + [{"role": "user", "parts": [_text_part(value)]}], separators=(",", ":") + ) + + messages = _parse_json_array(value) + if messages is None: + return value + + if all(isinstance(message, str) for message in messages): + string_messages = cast(List[str], messages) + return json.dumps( + [ + {"role": "user", "parts": [_text_part(message)]} + for message in string_messages + ], + separators=(",", ":"), + ) + + if all(isinstance(message, dict) for message in messages): + dict_messages = cast(List[Dict[str, object]], messages) + normalized_messages = [] + for message in dict_messages: + normalized = dict(message) + if "parts" not in normalized and "content" in normalized: + content = normalized.pop("content") + normalized["parts"] = _normalize_message_content(content) + normalized_messages.append(normalized) + return json.dumps(normalized_messages, separators=(",", ":")) + + return value + + +def _extract_response_text_parts( + value: Optional[AttributeValue], +) -> List[Dict[str, object]]: + if value is None: + return [] + + parsed = _parse_json(value) + if isinstance(value, str) and parsed is None: + return [_text_part(value)] + + if isinstance(parsed, str): + return [_text_part(parsed)] + + if isinstance(parsed, list): + parts: List[Dict[str, object]] = [] + for message in parsed: + if isinstance(message, str): + parts.append(_text_part(message)) + elif isinstance(message, dict): + content = message.get("content") + message_parts = message.get("parts") + if isinstance(content, str): + parts.append(_text_part(content)) + elif isinstance(message_parts, list): + parts.extend(message_parts) # type: ignore[arg-type] + return parts + + if isinstance(parsed, dict): + content = parsed.get("content") + message_parts = parsed.get("parts") + if isinstance(content, str): + return [_text_part(content)] + if isinstance(message_parts, list): + return message_parts # type: ignore[return-value] + + return [] + + +# Migrations + + +@attribute_migration( + id="gen_ai_request_messages_to_input_messages", + sources=[GEN_AI_REQUEST_MESSAGES], + replacement=ATTRIBUTE_NAMES.GEN_AI_INPUT_MESSAGES, +) +def _migrate_gen_ai_request_messages_to_input_messages( + attributes: AttributeMap, +) -> Optional[AttributeValue]: + value = attributes.get(GEN_AI_REQUEST_MESSAGES) + return None if value is None else _migrate_gen_ai_request_messages_value(value) + + +@attribute_migration( + id="gen_ai_response_to_output_messages", + sources=[GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS], + replacement=ATTRIBUTE_NAMES.GEN_AI_OUTPUT_MESSAGES, +) +def _migrate_gen_ai_response_to_output_messages( + attributes: AttributeMap, +) -> Optional[AttributeValue]: + text_value = attributes.get(GEN_AI_RESPONSE_TEXT) + tool_calls_value = attributes.get(GEN_AI_RESPONSE_TOOL_CALLS) + parts: List[Dict[str, object]] = _extract_response_text_parts(text_value) + + tool_calls = ( + _parse_json_array(tool_calls_value) if tool_calls_value is not None else None + ) + if tool_calls is not None: + for tool_call in tool_calls: + if isinstance(tool_call, dict): + part = dict(tool_call) + part["type"] = "tool_call" + parts.append(part) + + if parts: + return json.dumps( + [{"role": "assistant", "parts": parts}], separators=(",", ":") + ) + + return text_value if text_value is not None else tool_calls_value + + +# Public API + + def migrate_attribute_value( attribute_name: str, value: AttributeValue ) -> AttributeValue: @@ -67,18 +237,15 @@ def migrate_attribute_value( This helper only applies migrations that depend on one source attribute. Use :func:`migrate_deprecated_attributes` for migrations that need the full attribute map. """ - for migration in ATTRIBUTE_MIGRATIONS: if len(migration.sources) == 1 and migration.sources[0] == attribute_name: return migration.migrate({attribute_name: value}) or value return value -def _should_normalize_source(attribute_name: str) -> bool: - metadata = ATTRIBUTE_METADATA.get(attribute_name) - if not metadata or not metadata.deprecation: - return False - return metadata.deprecation.status == DeprecationStatus.NORMALIZE +def _should_normalize_source(source: str) -> bool: + deprecation = ATTRIBUTE_METADATA[source].deprecation + return deprecation is not None and deprecation.status == DeprecationStatus.NORMALIZE def migrate_deprecated_attributes(attributes: AttributeMap) -> None: @@ -88,7 +255,6 @@ def migrate_deprecated_attributes(attributes: AttributeMap) -> None: removed only after at least one source has deprecation status ``normalize``; during ``backfill``, both the source and replacement are kept. """ - for migration in ATTRIBUTE_MIGRATIONS: if not any(source in attributes for source in migration.sources): continue @@ -101,3 +267,10 @@ def migrate_deprecated_attributes(attributes: AttributeMap) -> None: if any(_should_normalize_source(source) for source in migration.sources): for source in migration.sources: attributes.pop(source, None) + + +__all__ = [ + "ATTRIBUTE_MIGRATIONS", + "migrate_attribute_value", + "migrate_deprecated_attributes", +] diff --git a/python/tests/test_migrations.py b/python/tests/test_migrations.py index e1d56d57..da9d5d13 100644 --- a/python/tests/test_migrations.py +++ b/python/tests/test_migrations.py @@ -1,16 +1,204 @@ +import json + +import pytest + +from sentry_conventions.attributes import ATTRIBUTE_NAMES from sentry_conventions.migrations import ( migrate_attribute_value, migrate_deprecated_attributes, ) -def test_migrate_attribute_value_leaves_unknown_attribute_unchanged(): - assert migrate_attribute_value("unknown.attribute", "value") == "value" +def migrated_request_messages(value): + return json.loads(migrate_attribute_value("gen_ai.request.messages", value)) + + +def output_messages(attributes): + return json.loads(attributes[ATTRIBUTE_NAMES.GEN_AI_OUTPUT_MESSAGES]) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ( + "hello", + [{"role": "user", "parts": [{"type": "text", "content": "hello"}]}], + ), + ( + json.dumps(["hello", "world"]), + [ + {"role": "user", "parts": [{"type": "text", "content": "hello"}]}, + {"role": "user", "parts": [{"type": "text", "content": "world"}]}, + ], + ), + ( + json.dumps([{"role": "user", "content": "hello"}]), + [{"role": "user", "parts": [{"type": "text", "content": "hello"}]}], + ), + ( + json.dumps( + [{"role": "system", "content": "hello", "response_metadata": {}}] + ), + [ + { + "role": "system", + "response_metadata": {}, + "parts": [{"type": "text", "content": "hello"}], + } + ], + ), + ( + json.dumps([{"role": "user", "content": [{"type": "text", "text": "hello"}]}]), + [{"role": "user", "parts": [{"type": "text", "text": "hello", "content": "hello"}]}], + ), + ( + json.dumps([{"role": "user", "parts": [{"type": "text", "content": "hello"}]}]), + [{"role": "user", "parts": [{"type": "text", "content": "hello"}]}], + ), + ], +) +def test_migrate_gen_ai_request_messages_to_input_messages_value(value, expected): + assert migrated_request_messages(value) == expected + + +@pytest.mark.parametrize("value", [42, True, ["hello"]]) +def test_migrate_gen_ai_request_messages_leaves_non_string_values_unchanged(value): + assert migrate_attribute_value("gen_ai.request.messages", value) == value + + +def test_migrate_deprecated_attributes_applies_request_message_migration(): + attributes = {"gen_ai.request.messages": json.dumps(["hello"])} + + migrate_deprecated_attributes(attributes) + + assert "gen_ai.request.messages" not in attributes + assert json.loads(attributes[ATTRIBUTE_NAMES.GEN_AI_INPUT_MESSAGES]) == [ + {"role": "user", "parts": [{"type": "text", "content": "hello"}]} + ] + + +@pytest.mark.parametrize( + ("text", "expected_parts"), + [ + ( + "The capital of France is Paris.", + [{"type": "text", "content": "The capital of France is Paris."}], + ), + ( + json.dumps(["The capital of France is Paris."]), + [{"type": "text", "content": "The capital of France is Paris."}], + ), + ( + json.dumps({"content": "Paris.", "role": "assistant", "tool_calls": "None"}), + [{"type": "text", "content": "Paris."}], + ), + ( + json.dumps( + { + "content": "Paris.", + "role": "assistant", + "annotations": [], + "audio": "None", + "refusal": "None", + } + ), + [{"type": "text", "content": "Paris."}], + ), + ( + json.dumps([{"role": "assistant", "content": "The capital of France is Paris."}]), + [{"type": "text", "content": "The capital of France is Paris."}], + ), + ( + json.dumps([{"role": "assistant", "parts": [{"type": "text", "content": "hello"}]}]), + [{"type": "text", "content": "hello"}], + ), + ], +) +def test_migrate_gen_ai_response_text_to_output_messages(text, expected_parts): + attributes = {"gen_ai.response.text": text} + + migrate_deprecated_attributes(attributes) + + assert "gen_ai.response.text" not in attributes + assert output_messages(attributes) == [{"role": "assistant", "parts": expected_parts}] + + +def test_migrate_gen_ai_response_tool_calls_to_output_messages(): + attributes = { + "gen_ai.response.tool_calls": json.dumps( + [{"name": "get_weather", "arguments": {"location": "Paris"}}] + ) + } + + migrate_deprecated_attributes(attributes) + + assert "gen_ai.response.tool_calls" not in attributes + assert output_messages(attributes) == [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": "get_weather", + "arguments": {"location": "Paris"}, + } + ], + } + ] + + +def test_migrate_gen_ai_response_text_and_tool_calls_to_output_messages(): + attributes = { + "gen_ai.response.text": json.dumps(["The weather is rainy."]), + "gen_ai.response.tool_calls": json.dumps( + [{"name": "get_weather", "arguments": {"location": "Paris"}}] + ), + } + + migrate_deprecated_attributes(attributes) + + assert "gen_ai.response.text" not in attributes + assert "gen_ai.response.tool_calls" not in attributes + assert output_messages(attributes) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": "The weather is rainy."}, + { + "type": "tool_call", + "name": "get_weather", + "arguments": {"location": "Paris"}, + }, + ], + } + ] + + +def test_migrate_deprecated_attributes_does_not_overwrite_existing_replacement(): + existing = json.dumps( + [{"role": "assistant", "parts": [{"type": "text", "content": "existing"}]}] + ) + attributes = { + "gen_ai.response.text": json.dumps(["new"]), + ATTRIBUTE_NAMES.GEN_AI_OUTPUT_MESSAGES: existing, + } + + migrate_deprecated_attributes(attributes) + + assert "gen_ai.response.text" not in attributes + assert attributes[ATTRIBUTE_NAMES.GEN_AI_OUTPUT_MESSAGES] == existing + + +def test_migrate_attribute_value_leaves_unknown_attributes_unchanged(): + assert migrate_attribute_value("unknown", "x") == "x" -def test_migrate_deprecated_attributes_leaves_unknown_attributes_unchanged(): - attributes = {"unknown.attribute": "value"} +def test_migrate_deprecated_attributes_preserves_unrelated_attributes(): + attributes = { + "unrelated": "kept", + "gen_ai.request.messages": json.dumps(["hello"]), + } migrate_deprecated_attributes(attributes) - assert attributes == {"unknown.attribute": "value"} + assert attributes["unrelated"] == "kept" diff --git a/rust/src/migrations.rs b/rust/src/migrations.rs index 830e77a1..ca332767 100644 --- a/rust/src/migrations.rs +++ b/rust/src/migrations.rs @@ -5,7 +5,13 @@ //! normalization so multi-source migrations can be applied and source attributes can be removed once //! their deprecation status switches to `normalize`. -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; + +use crate::attributes::{GEN_AI_INPUT_MESSAGES, GEN_AI_OUTPUT_MESSAGES}; + +const GEN_AI_REQUEST_MESSAGES: &str = "gen_ai.request.messages"; +const GEN_AI_RESPONSE_TEXT: &str = "gen_ai.response.text"; +const GEN_AI_RESPONSE_TOOL_CALLS: &str = "gen_ai.response.tool_calls"; #[derive(Clone, Copy)] pub struct AttributeMigration { @@ -24,7 +30,205 @@ pub struct AttributeMigration { function: fn(&Map) -> Option, } -pub const ATTRIBUTE_MIGRATIONS: &[AttributeMigration] = &[]; +// Utilities + +fn parse_json(value: &Value) -> Option { + value + .as_str() + .and_then(|s| serde_json::from_str::(s).ok()) +} + +fn parse_json_array(value: &Value) -> Option> { + parse_json(value).and_then(|value| match value { + Value::Array(items) => Some(items), + _ => None, + }) +} + +fn text_part(content: &str) -> Value { + json!({ "type": "text", "content": content }) +} + +fn normalize_message_content(content: &Value) -> Vec { + if let Some(content) = content.as_str() { + return vec![text_part(content)]; + } + + if let Value::Array(parts) = content { + return parts + .iter() + .map(|part| { + if let Some(text) = part.as_str() { + text_part(text) + } else if let Value::Object(object) = part { + let mut normalized = object.clone(); + if !normalized.contains_key("content") { + if let Some(text) = normalized.get("text").cloned() { + normalized.insert("content".to_owned(), text); + } + } + Value::Object(normalized) + } else { + part.clone() + } + }) + .collect(); + } + + vec![content.clone()] +} + +fn migrate_gen_ai_request_messages_value(value: Value) -> Value { + if value.is_string() && parse_json(&value).is_none() { + return Value::String( + serde_json::to_string( + &json!([{ "role": "user", "parts": [text_part(value.as_str().unwrap())] }]), + ) + .expect("gen_ai input message migration should serialize"), + ); + } + + let Some(messages) = parse_json_array(&value) else { + return value; + }; + + if messages.iter().all(Value::is_string) { + return Value::String( + serde_json::to_string( + &messages + .iter() + .filter_map(Value::as_str) + .map(|message| json!({ "role": "user", "parts": [text_part(message)] })) + .collect::>(), + ) + .expect("gen_ai input message migration should serialize"), + ); + } + + if messages.iter().all(Value::is_object) { + let migrated = messages + .iter() + .filter_map(Value::as_object) + .map(|message| { + let mut normalized = message.clone(); + if !normalized.contains_key("parts") { + if let Some(content) = normalized.remove("content") { + normalized.insert( + "parts".to_owned(), + Value::Array(normalize_message_content(&content)), + ); + } + } + Value::Object(normalized) + }) + .collect::>(); + + return Value::String( + serde_json::to_string(&migrated) + .expect("gen_ai input message migration should serialize"), + ); + } + + value +} + +fn extract_response_text_parts(value: Option<&Value>) -> Vec { + let Some(value) = value else { + return Vec::new(); + }; + + let parsed = parse_json(value); + if value.is_string() && parsed.is_none() { + return vec![text_part(value.as_str().unwrap())]; + } + + match parsed { + Some(Value::String(text)) => vec![text_part(&text)], + Some(Value::Array(messages)) => messages + .iter() + .flat_map(|message| { + if let Some(message) = message.as_str() { + vec![text_part(message)] + } else if let Some(message) = message.as_object() { + if let Some(content) = message.get("content").and_then(Value::as_str) { + vec![text_part(content)] + } else if let Some(parts) = message.get("parts").and_then(Value::as_array) { + parts.to_vec() + } else { + Vec::new() + } + } else { + Vec::new() + } + }) + .collect(), + Some(Value::Object(message)) => { + if let Some(content) = message.get("content").and_then(Value::as_str) { + vec![text_part(content)] + } else if let Some(parts) = message.get("parts").and_then(Value::as_array) { + parts.to_vec() + } else { + Vec::new() + } + } + _ => Vec::new(), + } +} + +// Migrations + +fn migrate_gen_ai_request_messages_to_input_messages( + attributes: &Map, +) -> Option { + attributes + .get(GEN_AI_REQUEST_MESSAGES) + .cloned() + .map(migrate_gen_ai_request_messages_value) +} + +fn migrate_gen_ai_response_to_output_messages(attributes: &Map) -> Option { + let text_value = attributes.get(GEN_AI_RESPONSE_TEXT); + let tool_calls_value = attributes.get(GEN_AI_RESPONSE_TOOL_CALLS); + let mut parts = extract_response_text_parts(text_value); + + if let Some(tool_calls) = tool_calls_value.and_then(parse_json_array) { + for tool_call in tool_calls { + if let Some(tool_call) = tool_call.as_object() { + let mut part = tool_call.clone(); + part.insert("type".to_owned(), Value::String("tool_call".to_owned())); + parts.push(Value::Object(part)); + } + } + } + + if !parts.is_empty() { + return Some(Value::String( + serde_json::to_string(&json!([{ "role": "assistant", "parts": parts }])) + .expect("gen_ai output message migration should serialize"), + )); + } + + text_value.or(tool_calls_value).cloned() +} + +pub const ATTRIBUTE_MIGRATIONS: &[AttributeMigration] = &[ + AttributeMigration { + id: "gen_ai_request_messages_to_input_messages", + sources: &[GEN_AI_REQUEST_MESSAGES], + replacement: GEN_AI_INPUT_MESSAGES, + remove_sources: true, + function: migrate_gen_ai_request_messages_to_input_messages, + }, + AttributeMigration { + id: "gen_ai_response_to_output_messages", + sources: &[GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS], + replacement: GEN_AI_OUTPUT_MESSAGES, + remove_sources: true, + function: migrate_gen_ai_response_to_output_messages, + }, +]; + +// Public API /// Migrates a single deprecated attribute value when the migration only depends on that attribute. /// @@ -44,8 +248,8 @@ pub fn migrate_attribute_value(attribute_name: &str, value: Value) -> Value { /// Applies all deprecated attribute migrations to an attribute map in-place. /// /// If a replacement attribute is already present, it is preserved. Source attributes are removed only -/// after at least one source has deprecation status `normalize`; during `backfill`, both the source -/// and replacement are kept. +/// after the migration is configured for normalization; during backfill, both the source and +/// replacement are kept. pub fn migrate_deprecated_attributes(attributes: &mut Map) { for migration in ATTRIBUTE_MIGRATIONS { if !migration diff --git a/rust/tests/migrations.rs b/rust/tests/migrations.rs index 49f7cc0f..9b634e72 100644 --- a/rust/tests/migrations.rs +++ b/rust/tests/migrations.rs @@ -1,25 +1,231 @@ +use sentry_conventions::attributes::{GEN_AI_INPUT_MESSAGES, GEN_AI_OUTPUT_MESSAGES}; use sentry_conventions::migrations::{migrate_attribute_value, migrate_deprecated_attributes}; use serde_json::{json, Map, Value}; +fn json_string(value: Value) -> Value { + Value::String(serde_json::to_string(&value).unwrap()) +} + +fn migrated_request_messages(value: Value) -> Value { + let migrated = migrate_attribute_value("gen_ai.request.messages", value); + serde_json::from_str(migrated.as_str().unwrap()).unwrap() +} + +fn output_messages(attributes: &Map) -> Value { + serde_json::from_str( + attributes + .get(GEN_AI_OUTPUT_MESSAGES) + .unwrap() + .as_str() + .unwrap(), + ) + .unwrap() +} + #[test] -fn migrate_attribute_value_leaves_unknown_attribute_unchanged() { +fn migrates_gen_ai_request_messages_shapes() { + let cases = [ + ( + Value::String("hello".to_owned()), + json!([{ "role": "user", "parts": [{ "type": "text", "content": "hello" }] }]), + ), + ( + json_string(json!(["hello", "world"])), + json!([ + { "role": "user", "parts": [{ "type": "text", "content": "hello" }] }, + { "role": "user", "parts": [{ "type": "text", "content": "world" }] } + ]), + ), + ( + json_string(json!([{ "role": "user", "content": "hello" }])), + json!([{ "role": "user", "parts": [{ "type": "text", "content": "hello" }] }]), + ), + ( + json_string(json!([{ "role": "system", "content": "hello", "response_metadata": {} }])), + json!([{ "role": "system", "response_metadata": {}, "parts": [{ "type": "text", "content": "hello" }] }]), + ), + ( + json_string( + json!([{ "role": "user", "content": [{ "type": "text", "text": "hello" }] }]), + ), + json!([{ "role": "user", "parts": [{ "type": "text", "text": "hello", "content": "hello" }] }]), + ), + ( + json_string( + json!([{ "role": "user", "parts": [{ "type": "text", "content": "hello" }] }]), + ), + json!([{ "role": "user", "parts": [{ "type": "text", "content": "hello" }] }]), + ), + ]; + + for (input, expected) in cases { + assert_eq!(migrated_request_messages(input), expected); + } +} + +#[test] +fn leaves_non_string_request_message_values_unchanged() { + for value in [json!(42), json!(true), json!(["hello"])] { + assert_eq!( + migrate_attribute_value("gen_ai.request.messages", value.clone()), + value + ); + } +} + +#[test] +fn migrate_deprecated_attributes_applies_request_message_migration() { + let mut attributes = Map::from_iter([( + "gen_ai.request.messages".to_owned(), + json_string(json!(["hello"])), + )]); + + migrate_deprecated_attributes(&mut attributes); + + assert!(!attributes.contains_key("gen_ai.request.messages")); assert_eq!( - migrate_attribute_value("unknown.attribute", json!("value")), - json!("value") + serde_json::from_str::( + attributes + .get(GEN_AI_INPUT_MESSAGES) + .unwrap() + .as_str() + .unwrap() + ) + .unwrap(), + json!([{ "role": "user", "parts": [{ "type": "text", "content": "hello" }] }]) ); } #[test] -fn migrate_deprecated_attributes_leaves_unknown_attributes_unchanged() { - let mut attributes = Map::from_iter([("unknown.attribute".to_owned(), json!("value"))]); +fn migrates_gen_ai_response_text_shapes() { + let cases = [ + ( + Value::String("The capital of France is Paris.".to_owned()), + json!([{ "type": "text", "content": "The capital of France is Paris." }]), + ), + ( + json_string(json!(["The capital of France is Paris."])), + json!([{ "type": "text", "content": "The capital of France is Paris." }]), + ), + ( + json_string(json!({ "content": "Paris.", "role": "assistant", "tool_calls": "None" })), + json!([{ "type": "text", "content": "Paris." }]), + ), + ( + json_string( + json!({ "content": "Paris.", "role": "assistant", "annotations": [], "audio": "None", "refusal": "None" }), + ), + json!([{ "type": "text", "content": "Paris." }]), + ), + ( + json_string( + json!([{ "role": "assistant", "content": "The capital of France is Paris." }]), + ), + json!([{ "type": "text", "content": "The capital of France is Paris." }]), + ), + ( + json_string( + json!([{ "role": "assistant", "parts": [{ "type": "text", "content": "hello" }] }]), + ), + json!([{ "type": "text", "content": "hello" }]), + ), + ]; + + for (input, expected_parts) in cases { + let mut attributes = Map::from_iter([("gen_ai.response.text".to_owned(), input)]); + migrate_deprecated_attributes(&mut attributes); + assert!(!attributes.contains_key("gen_ai.response.text")); + assert_eq!( + output_messages(&attributes), + json!([{ "role": "assistant", "parts": expected_parts }]) + ); + } +} + +#[test] +fn migrates_gen_ai_response_tool_calls_to_output_messages() { + let mut attributes = Map::from_iter([( + "gen_ai.response.tool_calls".to_owned(), + json_string(json!([{ "name": "get_weather", "arguments": { "location": "Paris" } }])), + )]); migrate_deprecated_attributes(&mut attributes); + assert!(!attributes.contains_key("gen_ai.response.tool_calls")); assert_eq!( - attributes, - Map::from_iter([( - "unknown.attribute".to_owned(), - Value::String("value".to_owned()) - )]) + output_messages(&attributes), + json!([{ "role": "assistant", "parts": [{ "type": "tool_call", "name": "get_weather", "arguments": { "location": "Paris" } }] }]) ); } + +#[test] +fn migrates_gen_ai_response_text_and_tool_calls_to_output_messages() { + let mut attributes = Map::from_iter([ + ( + "gen_ai.response.text".to_owned(), + json_string(json!(["The weather is rainy."])), + ), + ( + "gen_ai.response.tool_calls".to_owned(), + json_string(json!([{ "name": "get_weather", "arguments": { "location": "Paris" } }])), + ), + ]); + + migrate_deprecated_attributes(&mut attributes); + + assert!(!attributes.contains_key("gen_ai.response.text")); + assert!(!attributes.contains_key("gen_ai.response.tool_calls")); + assert_eq!( + output_messages(&attributes), + json!([{ "role": "assistant", "parts": [ + { "type": "text", "content": "The weather is rainy." }, + { "type": "tool_call", "name": "get_weather", "arguments": { "location": "Paris" } } + ] }]) + ); +} + +#[test] +fn does_not_overwrite_existing_replacement() { + let existing = serde_json::to_string( + &json!([{ "role": "assistant", "parts": [{ "type": "text", "content": "existing" }] }]), + ) + .unwrap(); + let mut attributes = Map::from_iter([ + ( + "gen_ai.response.text".to_owned(), + json_string(json!(["new"])), + ), + ( + GEN_AI_OUTPUT_MESSAGES.to_owned(), + Value::String(existing.clone()), + ), + ]); + + migrate_deprecated_attributes(&mut attributes); + + assert!(!attributes.contains_key("gen_ai.response.text")); + assert_eq!( + attributes.get(GEN_AI_OUTPUT_MESSAGES).unwrap().as_str(), + Some(existing.as_str()) + ); +} + +#[test] +fn unknown_attribute_value_migration_is_unchanged() { + assert_eq!(migrate_attribute_value("unknown", json!("x")), json!("x")); +} + +#[test] +fn unrelated_attributes_are_preserved() { + let mut attributes = Map::from_iter([ + ("unrelated".to_owned(), json!("kept")), + ( + "gen_ai.request.messages".to_owned(), + json_string(json!(["hello"])), + ), + ]); + + migrate_deprecated_attributes(&mut attributes); + + assert_eq!(attributes.get("unrelated"), Some(&json!("kept"))); +} diff --git a/test/migrations.test.ts b/test/migrations.test.ts index 244d4155..79d1d18a 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -1,17 +1,178 @@ import { describe, expect, it } from 'vitest'; +import { + GEN_AI_INPUT_MESSAGES, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_REQUEST_MESSAGES, + GEN_AI_RESPONSE_TEXT, + GEN_AI_RESPONSE_TOOL_CALLS, + type AttributeValue, +} from '../javascript/sentry-conventions/src/attributes'; import { migrateAttributeValue, migrateDeprecatedAttributes } from '../javascript/sentry-conventions/src/migrations'; -describe('attribute migration utilities', () => { - it('leaves single values unchanged when no migration is registered', () => { - expect(migrateAttributeValue('unknown.attribute', 'value')).toBe('value'); +function migrateRequestMessages(value: AttributeValue): unknown { + return JSON.parse(migrateAttributeValue(GEN_AI_REQUEST_MESSAGES, value) as string); +} + +function migrateAttributes( + attributes: Record, +): Record { + migrateDeprecatedAttributes(attributes); + return attributes; +} + +function outputMessages(attributes: Record): unknown { + return JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES] as string); +} + +describe('gen_ai_request_messages_to_input_messages', () => { + it.each([ + { + name: 'plain string', + input: 'hello', + expected: [{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }], + }, + { + name: 'string array', + input: JSON.stringify(['hello', 'world']), + expected: [ + { role: 'user', parts: [{ type: 'text', content: 'hello' }] }, + { role: 'user', parts: [{ type: 'text', content: 'world' }] }, + ], + }, + { + name: 'message objects with content string', + input: JSON.stringify([{ role: 'user', content: 'hello' }]), + expected: [{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }], + }, + { + name: 'message objects with metadata', + input: JSON.stringify([{ role: 'system', content: 'hello', response_metadata: {} }]), + expected: [{ role: 'system', response_metadata: {}, parts: [{ type: 'text', content: 'hello' }] }], + }, + { + name: 'message objects with content array', + input: JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'hello' }] }]), + expected: [{ role: 'user', parts: [{ type: 'text', text: 'hello', content: 'hello' }] }], + }, + { + name: 'already migrated parts', + input: JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }]), + expected: [{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }], + }, + ])('migrates $name', ({ input, expected }) => { + expect(migrateRequestMessages(input)).toEqual(expected); + }); + + it.each([42, true, ['hello']])('leaves non-string values unchanged: %j', (input) => { + expect(migrateAttributeValue(GEN_AI_REQUEST_MESSAGES, input as AttributeValue)).toEqual(input); + }); + + it('normalizes through migrateDeprecatedAttributes', () => { + const attributes = migrateAttributes({ [GEN_AI_REQUEST_MESSAGES]: JSON.stringify(['hello']) }); + + expect(attributes[GEN_AI_REQUEST_MESSAGES]).toBeUndefined(); + expect(JSON.parse(attributes[GEN_AI_INPUT_MESSAGES] as string)).toEqual([ + { role: 'user', parts: [{ type: 'text', content: 'hello' }] }, + ]); + }); +}); + +describe('gen_ai_response_to_output_messages', () => { + it.each([ + { + name: 'plain string', + text: 'The capital of France is Paris.', + expectedParts: [{ type: 'text', content: 'The capital of France is Paris.' }], + }, + { + name: 'JSON string array', + text: JSON.stringify(['The capital of France is Paris.']), + expectedParts: [{ type: 'text', content: 'The capital of France is Paris.' }], + }, + { + name: 'JSON object with content', + text: JSON.stringify({ content: 'Paris.', role: 'assistant', tool_calls: 'None' }), + expectedParts: [{ type: 'text', content: 'Paris.' }], + }, + { + name: 'JSON object with content and extra fields', + text: JSON.stringify({ content: 'Paris.', role: 'assistant', annotations: [], audio: 'None', refusal: 'None' }), + expectedParts: [{ type: 'text', content: 'Paris.' }], + }, + { + name: 'message array with assistant message', + text: JSON.stringify([{ role: 'assistant', content: 'The capital of France is Paris.' }]), + expectedParts: [{ type: 'text', content: 'The capital of France is Paris.' }], + }, + { + name: 'message array with existing parts', + text: JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'hello' }] }]), + expectedParts: [{ type: 'text', content: 'hello' }], + }, + ])('migrates response text: $name', ({ text, expectedParts }) => { + const attributes = migrateAttributes({ [GEN_AI_RESPONSE_TEXT]: text }); + + expect(attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(outputMessages(attributes)).toEqual([{ role: 'assistant', parts: expectedParts }]); }); - it('leaves attribute maps unchanged when no migration is registered', () => { - const attributes = { 'unknown.attribute': 'value' }; + it('migrates tool calls only', () => { + const attributes = migrateAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS]: JSON.stringify([{ name: 'get_weather', arguments: { location: 'Paris' } }]), + }); + + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeUndefined(); + expect(outputMessages(attributes)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', name: 'get_weather', arguments: { location: 'Paris' } }], + }, + ]); + }); + + it('merges response text and tool calls', () => { + const attributes = migrateAttributes({ + [GEN_AI_RESPONSE_TEXT]: JSON.stringify(['The weather is rainy.']), + [GEN_AI_RESPONSE_TOOL_CALLS]: JSON.stringify([{ name: 'get_weather', arguments: { location: 'Paris' } }]), + }); + + expect(attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeUndefined(); + expect(outputMessages(attributes)).toEqual([ + { + role: 'assistant', + parts: [ + { type: 'text', content: 'The weather is rainy.' }, + { type: 'tool_call', name: 'get_weather', arguments: { location: 'Paris' } }, + ], + }, + ]); + }); + + it('preserves existing replacement and removes sources', () => { + const existing = JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'existing' }] }]); + const attributes = migrateAttributes({ + [GEN_AI_RESPONSE_TEXT]: JSON.stringify(['new']), + [GEN_AI_OUTPUT_MESSAGES]: existing, + }); + + expect(attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(attributes[GEN_AI_OUTPUT_MESSAGES]).toBe(existing); + }); +}); + +describe('migration orchestration', () => { + it('leaves unknown attributes unchanged in direct value migration', () => { + expect(migrateAttributeValue('unknown', 'x')).toBe('x'); + }); - migrateDeprecatedAttributes(attributes); + it('preserves unrelated attributes', () => { + const attributes = migrateAttributes({ + unrelated: 'kept', + [GEN_AI_REQUEST_MESSAGES]: JSON.stringify(['hello']), + }); - expect(attributes).toEqual({ 'unknown.attribute': 'value' }); + expect(attributes.unrelated).toBe('kept'); }); });