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'); }); });