From 7a701281bcdf236b618bdcbb1ff846d6701d7a47 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 15:20:35 +0200 Subject: [PATCH 1/4] feat(pydantic-ai): Emit OTEL output messages and reasoning Capture ThinkingPart as reasoning, write gen_ai.output.messages on chat spans, and record provider reasoning token usage. Align request text parts to content and stop dual-writing deprecated gen_ai.response.text. Co-Authored-By: opencode --- .../pydantic_ai/spans/ai_client.py | 126 +++++++--- .../pydantic_ai/spans/invoke_agent.py | 6 +- .../integrations/pydantic_ai/spans/utils.py | 25 +- .../pydantic_ai/test_pydantic_ai.py | 217 +++++++++++++++++- 4 files changed, 328 insertions(+), 46 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 27deb0c55c..98821613b2 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -32,7 +32,7 @@ ) if TYPE_CHECKING: - from typing import Any, Dict, List, Union + from typing import Any, Dict, List, Optional, Union from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore @@ -61,6 +61,22 @@ ImageUrl = None +def _nonempty_content(value: "Any") -> "Optional[str]": + if value is None: + return None + return str(value) or None + + +def _tool_call_arguments(args: "Any") -> "Any": + """Prefer structured args for OTEL tool_call parts; keep raw string if not JSON.""" + if not isinstance(args, str) or not args.strip(): + return args + try: + return json.loads(args) + except ValueError: + return args + + def _transform_system_instructions( permanent_instructions: "list[SystemPromptPart]", current_instructions: "list[str]", @@ -170,15 +186,20 @@ def _set_input_messages( if hasattr(part, "tool_name"): tool_call_id = part.tool_name if hasattr(part, "content"): - content.append({"type": "text", "text": str(part.content)}) - # Handle regular content + content.append( + {"type": "text", "content": str(part.content)} + ) + elif ThinkingPart and isinstance(part, ThinkingPart): + reasoning = _nonempty_content(part.content) + if reasoning is not None: + content.append({"type": "reasoning", "content": reasoning}) elif hasattr(part, "content"): if isinstance(part.content, str): - content.append({"type": "text", "text": part.content}) + content.append({"type": "text", "content": part.content}) elif isinstance(part.content, list): for item in part.content: if isinstance(item, str): - content.append({"type": "text", "text": item}) + content.append({"type": "text", "content": item}) elif ImageUrl and isinstance(item, ImageUrl): content.append(_serialize_image_url_item(item)) elif BinaryContent and isinstance(item, BinaryContent): @@ -186,7 +207,9 @@ def _set_input_messages( else: content.append(safe_serialize(item)) else: - content.append({"type": "text", "text": str(part.content)}) + content.append( + {"type": "text", "content": str(part.content)} + ) # Add message if we have content or tool calls if content or tool_calls: message: "Dict[str, Any]" = {"role": role} @@ -231,31 +254,72 @@ def _set_output_data( set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) try: - # Extract text from ModelResponse - if hasattr(response, "parts"): - texts = [] - tool_calls = [] - - for part in response.parts: - if TextPart and isinstance(part, TextPart) and hasattr(part, "content"): - texts.append(part.content) - elif BaseToolCallPart and isinstance(part, BaseToolCallPart): - tool_call_data = { - "type": "function", - } - if hasattr(part, "tool_name"): - tool_call_data["name"] = part.tool_name - if hasattr(part, "args"): - tool_call_data["arguments"] = safe_serialize(part.args) - tool_calls.append(tool_call_data) - - if texts: - set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, texts) - - if tool_calls: - set_on_span( - SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls) - ) + # Prefer OTEL gen_ai.output.messages (includes text + reasoning + tool_call); + # keep deprecated response.tool_calls for compatibility. + if not hasattr(response, "parts"): + return + + tool_calls = [] + message_parts = [] # type: List[Dict[str, Any]] + + for part in response.parts: + if ThinkingPart and isinstance(part, ThinkingPart): + reasoning = _nonempty_content(getattr(part, "content", None)) + if reasoning is not None: + message_parts.append({"type": "reasoning", "content": reasoning}) + continue + + if TextPart and isinstance(part, TextPart) and hasattr(part, "content"): + text = _nonempty_content(part.content) + if text is not None: + message_parts.append({"type": "text", "content": text}) + continue + + if not (BaseToolCallPart and isinstance(part, BaseToolCallPart)): + continue + + name = getattr(part, "tool_name", None) + has_args = hasattr(part, "args") + args = part.args if has_args else None + + # Deprecated response.tool_calls shape + tool_call_data = {"type": "function"} # type: Dict[str, Any] + if name is not None: + tool_call_data["name"] = name + if has_args: + tool_call_data["arguments"] = safe_serialize(args) + tool_calls.append(tool_call_data) + + if not name: + continue + + otel_tool_call = {"type": "tool_call", "name": name} # type: Dict[str, Any] + tool_call_id = getattr(part, "tool_call_id", None) or getattr( + part, "id", None + ) + if tool_call_id: + otel_tool_call["id"] = tool_call_id + if has_args: + otel_tool_call["arguments"] = _tool_call_arguments(args) + message_parts.append(otel_tool_call) + + if tool_calls: + set_on_span(SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)) + + if message_parts: + output_message = { + "role": "assistant", + "parts": message_parts, + } # type: Dict[str, Any] + finish_reason = getattr(response, "finish_reason", None) + if finish_reason is not None: + output_message["finish_reason"] = str(finish_reason) + set_data_normalized( + span, + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + [output_message], + unpack=False, + ) except Exception: # If we fail to format output, just skip it diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index f0c68e85ba..f3b56063c6 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -103,7 +103,7 @@ def invoke_agent_span( for system_text in system_texts: messages.append( { - "content": [{"text": system_text, "type": "text"}], + "content": [{"type": "text", "content": system_text}], "role": "system", } ) @@ -113,7 +113,7 @@ def invoke_agent_span( if isinstance(user_prompt, str): messages.append( { - "content": [{"text": user_prompt, "type": "text"}], + "content": [{"type": "text", "content": user_prompt}], "role": "user", } ) @@ -122,7 +122,7 @@ def invoke_agent_span( content = [] for item in user_prompt: if isinstance(item, str): - content.append({"text": item, "type": "text"}) + content.append({"type": "text", "content": item}) elif ImageUrl and isinstance(item, ImageUrl): content.append(_serialize_image_url_item(item)) elif BinaryContent and isinstance(item, BinaryContent): diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py index 330496c6b2..8e3262a783 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -10,7 +10,7 @@ from sentry_sdk.traces import StreamedSpan if TYPE_CHECKING: - from typing import Any, Dict, Union + from typing import Any, Dict, Optional, Union from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore @@ -85,3 +85,26 @@ def _set_usage_data( if hasattr(usage, "total_tokens") and usage.total_tokens is not None: set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_tokens) + + reasoning_tokens = _reasoning_token_count(usage) + if reasoning_tokens is not None: + set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, reasoning_tokens) + + +def _reasoning_token_count(usage: "Any") -> "Optional[int]": + """Provider-specific reasoning token counts live on usage.details or the object itself.""" + details = getattr(usage, "details", None) + if isinstance(details, dict): + for key in ( + "reasoning_tokens", + "thinking_tokens", + "thoughts_token_count", + "thoughts_tokens", + "output_tokens.reasoning", + ): + value = details.get(key) + if isinstance(value, int) and value > 0: + return value + + value = getattr(usage, "reasoning_tokens", None) + return value if isinstance(value, int) and value > 0 else None diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 51a1072690..c6e90a45b6 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -129,11 +129,11 @@ async def test_agent_run_async( "content": [ { "type": "text", - "text": "Message demonstrating the absence of truncation.", + "content": "Message demonstrating the absence of truncation.", }, { "type": "text", - "text": "Test input", + "content": "Test input", }, ], } @@ -179,11 +179,11 @@ async def test_agent_run_async( "content": [ { "type": "text", - "text": "Message demonstrating the absence of truncation.", + "content": "Message demonstrating the absence of truncation.", }, { "type": "text", - "text": "Test input", + "content": "Test input", }, ], } @@ -511,11 +511,11 @@ async def test_agent_run_stream( "content": [ { "type": "text", - "text": "Message demonstrating the absence of truncation.", + "content": "Message demonstrating the absence of truncation.", }, { "type": "text", - "text": "Test input", + "content": "Test input", }, ], } @@ -523,7 +523,7 @@ async def test_agent_run_stream( assert "gen_ai.usage.input_tokens" in chat_span["attributes"] # Streaming responses should still have output data assert ( - "gen_ai.response.text" in chat_span["attributes"] + "gen_ai.output.messages" in chat_span["attributes"] or "gen_ai.response.model" in chat_span["attributes"] ) elif stream_gen_ai_spans: @@ -561,11 +561,11 @@ async def test_agent_run_stream( "content": [ { "type": "text", - "text": "Message demonstrating the absence of truncation.", + "content": "Message demonstrating the absence of truncation.", }, { "type": "text", - "text": "Test input", + "content": "Test input", }, ], } @@ -573,7 +573,7 @@ async def test_agent_run_stream( assert "gen_ai.usage.input_tokens" in chat_span["attributes"] # Streaming responses should still have output data assert ( - "gen_ai.response.text" in chat_span["attributes"] + "gen_ai.output.messages" in chat_span["attributes"] or "gen_ai.response.model" in chat_span["attributes"] ) else: @@ -602,7 +602,7 @@ async def test_agent_run_stream( assert "gen_ai.usage.input_tokens" in chat_span["data"] # Streaming responses should still have output data assert ( - "gen_ai.response.text" in chat_span["data"] + "gen_ai.output.messages" in chat_span["data"] or "gen_ai.response.model" in chat_span["data"] ) @@ -3113,6 +3113,143 @@ async def test_output_data_with_text_and_tool_calls(sentry_init, capture_items): assert transaction is not None +@pytest.mark.asyncio +async def test_output_data_with_thinking_part(sentry_init): + """ThinkingPart is captured as OTEL reasoning in gen_ai.output.messages.""" + from pydantic_ai import messages + + import sentry_sdk + from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + with sentry_sdk.start_transaction(op="test", name="test"): + span = sentry_sdk.start_span(op="test_span") + + mock_response = MagicMock() + mock_response.model_name = "test-model" + mock_response.finish_reason = "stop" + mock_response.parts = [ + messages.ThinkingPart(content="Let me reason step by step"), + messages.TextPart(content="Final answer"), + ] + + _set_output_data(span, mock_response) + span.finish() + + assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span._data + output_messages = json.loads(span._data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) + assert output_messages == [ + { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "Let me reason step by step"}, + {"type": "text", "content": "Final answer"}, + ], + "finish_reason": "stop", + } + ] + + +@pytest.mark.asyncio +async def test_input_messages_with_thinking_part(sentry_init): + """ThinkingPart in history is serialized as type=reasoning, not plain text.""" + from pydantic_ai import messages + + import sentry_sdk + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + history = [ + messages.ModelRequest( + parts=[messages.UserPromptPart(content="Explain gravity")] + ), + messages.ModelResponse( + parts=[ + messages.ThinkingPart(content="Newtonian approximation first"), + messages.TextPart(content="Gravity attracts mass."), + ], + model_name="test", + ), + ] + + with sentry_sdk.start_transaction(op="test", name="test"): + span = sentry_sdk.start_span(op="test_span") + _set_input_messages(span, history) + span.finish() + + request_messages = json.loads(span._data[SPANDATA.GEN_AI_REQUEST_MESSAGES]) + assert { + "role": "assistant", + "content": [ + { + "type": "reasoning", + "content": "Newtonian approximation first", + } + ], + } in request_messages + assert { + "role": "assistant", + "content": [{"type": "text", "content": "Gravity attracts mass."}], + } in request_messages + + +@pytest.mark.asyncio +async def test_output_data_with_otel_tool_call(sentry_init): + """ToolCallPart is captured as OTEL tool_call in gen_ai.output.messages.""" + from pydantic_ai import messages + + import sentry_sdk + from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_output_data + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + with sentry_sdk.start_transaction(op="test", name="test"): + span = sentry_sdk.start_span(op="test_span") + + mock_response = MagicMock() + mock_response.model_name = "test-model" + mock_response.finish_reason = "tool_call" + mock_response.parts = [ + messages.ToolCallPart( + tool_name="get_weather", + args='{"city": "Berlin"}', + tool_call_id="call_1", + ), + ] + + _set_output_data(span, mock_response) + span.finish() + + output_messages = json.loads(span._data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) + assert output_messages == [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": "get_weather", + "id": "call_1", + "arguments": {"city": "Berlin"}, + } + ], + "finish_reason": "tool_call", + } + ] + + @pytest.mark.asyncio async def test_output_data_error_handling(sentry_init, capture_items): """ @@ -4287,6 +4424,64 @@ async def test_set_usage_data_with_cache_tokens( assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20 +@pytest.mark.parametrize( + "details_key", + [ + "reasoning_tokens", + "thinking_tokens", + "thoughts_token_count", + "thoughts_tokens", + "output_tokens.reasoning", + ], +) +def test_set_usage_data_reasoning_token_detail_keys( + sentry_init, capture_events, details_key +): + """Provider-specific reasoning counts live under usage.details with different keys.""" + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(op="test", name="test"): + span = sentry_sdk.start_span(op="test_span") + usage = RequestUsage( + input_tokens=100, + output_tokens=50, + details={details_key: 12}, + ) + _set_usage_data(span, usage) + span.finish() + + (event,) = events + (span_data,) = event["spans"] + assert span_data["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 12 + + +def test_set_usage_data_omits_zero_reasoning_tokens(sentry_init, capture_events): + """Zero reasoning token counts must not be written (providers often default to 0).""" + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(op="test", name="test"): + span = sentry_sdk.start_span(op="test_span") + usage = RequestUsage( + input_tokens=100, + output_tokens=50, + details={"reasoning_tokens": 0}, + ) + _set_usage_data(span, usage) + span.finish() + + (event,) = events + (span_data,) = event["spans"] + assert SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING not in span_data["data"] + + @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) @pytest.mark.parametrize( From 440a096b44c81e291acf8b77e32a531cf517475a Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 18:32:12 +0200 Subject: [PATCH 2/4] fix(pydantic-ai): Keep request message text key for truncation gen_ai.request.messages text and reasoning parts must use the text key so truncate_and_annotate_messages still truncates long prompts. Output messages continue to use content per the OTEL shape. Co-Authored-By: opencode --- .../pydantic_ai/spans/ai_client.py | 14 +++++-------- .../pydantic_ai/spans/invoke_agent.py | 6 +++--- .../pydantic_ai/test_pydantic_ai.py | 20 +++++++++---------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 98821613b2..f05bc89f6e 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -186,20 +186,18 @@ def _set_input_messages( if hasattr(part, "tool_name"): tool_call_id = part.tool_name if hasattr(part, "content"): - content.append( - {"type": "text", "content": str(part.content)} - ) + content.append({"type": "text", "text": str(part.content)}) elif ThinkingPart and isinstance(part, ThinkingPart): reasoning = _nonempty_content(part.content) if reasoning is not None: - content.append({"type": "reasoning", "content": reasoning}) + content.append({"type": "reasoning", "text": reasoning}) elif hasattr(part, "content"): if isinstance(part.content, str): - content.append({"type": "text", "content": part.content}) + content.append({"type": "text", "text": part.content}) elif isinstance(part.content, list): for item in part.content: if isinstance(item, str): - content.append({"type": "text", "content": item}) + content.append({"type": "text", "text": item}) elif ImageUrl and isinstance(item, ImageUrl): content.append(_serialize_image_url_item(item)) elif BinaryContent and isinstance(item, BinaryContent): @@ -207,9 +205,7 @@ def _set_input_messages( else: content.append(safe_serialize(item)) else: - content.append( - {"type": "text", "content": str(part.content)} - ) + content.append({"type": "text", "text": str(part.content)}) # Add message if we have content or tool calls if content or tool_calls: message: "Dict[str, Any]" = {"role": role} diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index f3b56063c6..f0c68e85ba 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -103,7 +103,7 @@ def invoke_agent_span( for system_text in system_texts: messages.append( { - "content": [{"type": "text", "content": system_text}], + "content": [{"text": system_text, "type": "text"}], "role": "system", } ) @@ -113,7 +113,7 @@ def invoke_agent_span( if isinstance(user_prompt, str): messages.append( { - "content": [{"type": "text", "content": user_prompt}], + "content": [{"text": user_prompt, "type": "text"}], "role": "user", } ) @@ -122,7 +122,7 @@ def invoke_agent_span( content = [] for item in user_prompt: if isinstance(item, str): - content.append({"type": "text", "content": item}) + content.append({"text": item, "type": "text"}) elif ImageUrl and isinstance(item, ImageUrl): content.append(_serialize_image_url_item(item)) elif BinaryContent and isinstance(item, BinaryContent): diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index c6e90a45b6..3645b2cfb1 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -129,11 +129,11 @@ async def test_agent_run_async( "content": [ { "type": "text", - "content": "Message demonstrating the absence of truncation.", + "text": "Message demonstrating the absence of truncation.", }, { "type": "text", - "content": "Test input", + "text": "Test input", }, ], } @@ -179,11 +179,11 @@ async def test_agent_run_async( "content": [ { "type": "text", - "content": "Message demonstrating the absence of truncation.", + "text": "Message demonstrating the absence of truncation.", }, { "type": "text", - "content": "Test input", + "text": "Test input", }, ], } @@ -511,11 +511,11 @@ async def test_agent_run_stream( "content": [ { "type": "text", - "content": "Message demonstrating the absence of truncation.", + "text": "Message demonstrating the absence of truncation.", }, { "type": "text", - "content": "Test input", + "text": "Test input", }, ], } @@ -561,11 +561,11 @@ async def test_agent_run_stream( "content": [ { "type": "text", - "content": "Message demonstrating the absence of truncation.", + "text": "Message demonstrating the absence of truncation.", }, { "type": "text", - "content": "Test input", + "text": "Test input", }, ], } @@ -3192,13 +3192,13 @@ async def test_input_messages_with_thinking_part(sentry_init): "content": [ { "type": "reasoning", - "content": "Newtonian approximation first", + "text": "Newtonian approximation first", } ], } in request_messages assert { "role": "assistant", - "content": [{"type": "text", "content": "Gravity attracts mass."}], + "content": [{"type": "text", "text": "Gravity attracts mass."}], } in request_messages From d038a819ef647ed123f8007c564fec55e5a86fcd Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 18:48:30 +0200 Subject: [PATCH 3/4] ref(pydantic-ai): Stop writing deprecated response.tool_calls Tool calls already live on gen_ai.output.messages; drop the second attribute on chat spans. Co-Authored-By: opencode --- .../pydantic_ai/spans/ai_client.py | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index f05bc89f6e..cfbe49d82c 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -250,12 +250,10 @@ def _set_output_data( set_on_span(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_name) try: - # Prefer OTEL gen_ai.output.messages (includes text + reasoning + tool_call); - # keep deprecated response.tool_calls for compatibility. + # OTEL gen_ai.output.messages (text + reasoning + tool_call). if not hasattr(response, "parts"): return - tool_calls = [] message_parts = [] # type: List[Dict[str, Any]] for part in response.parts: @@ -275,17 +273,6 @@ def _set_output_data( continue name = getattr(part, "tool_name", None) - has_args = hasattr(part, "args") - args = part.args if has_args else None - - # Deprecated response.tool_calls shape - tool_call_data = {"type": "function"} # type: Dict[str, Any] - if name is not None: - tool_call_data["name"] = name - if has_args: - tool_call_data["arguments"] = safe_serialize(args) - tool_calls.append(tool_call_data) - if not name: continue @@ -295,13 +282,10 @@ def _set_output_data( ) if tool_call_id: otel_tool_call["id"] = tool_call_id - if has_args: - otel_tool_call["arguments"] = _tool_call_arguments(args) + if hasattr(part, "args"): + otel_tool_call["arguments"] = _tool_call_arguments(part.args) message_parts.append(otel_tool_call) - if tool_calls: - set_on_span(SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)) - if message_parts: output_message = { "role": "assistant", From bc60b707043d880aac823b8949e12a2a42ec0544 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Mon, 3 Aug 2026 18:53:28 +0200 Subject: [PATCH 4/4] ref(pydantic-ai): Write agent output as gen_ai.output.messages Stop setting deprecated gen_ai.response.text on invoke_agent spans; use the OTEL output messages shape instead. Co-Authored-By: opencode --- .../integrations/pydantic_ai/spans/invoke_agent.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index f0c68e85ba..c2e756d754 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -162,10 +162,17 @@ def update_invoke_agent_span( # Extract output from result output = getattr(result, "output", None) - # Set response text if prompts are enabled if _should_send_prompts() and output: set_data_normalized( - span, SPANDATA.GEN_AI_RESPONSE_TEXT, str(output), unpack=False + span, + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + [ + { + "role": "assistant", + "parts": [{"type": "text", "content": str(output)}], + } + ], + unpack=False, ) # Set model name from response if available