From d246712c675bcb37e057696e0d71bccaad1375bd Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Thu, 5 Mar 2026 14:58:06 -0500 Subject: [PATCH 1/7] feat(pydantic-ai): Add tool description to execute_tool spans Include the tool's description (from its docstring) in gen_ai.execute_tool spans via the gen_ai.tool.description span data attribute. The description is sourced from the tool's ToolDefinition, which is derived from the tool's docstring. Co-Authored-By: Claude Sonnet 4.6 --- .../integrations/pydantic_ai/patches/tools.py | 4 + .../pydantic_ai/spans/execute_tool.py | 11 +- .../pydantic_ai/test_pydantic_ai.py | 142 +++++++++++++++++- 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/tools.py b/sentry_sdk/integrations/pydantic_ai/patches/tools.py index e318b1322f..1f5cde8742 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/tools.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/tools.py @@ -50,6 +50,7 @@ async def wrapped_execute_tool_call( call = validated.call name = call.tool_name tool = self.tools.get(name) if self.tools else None + selected_tool_definition = getattr(tool, "tool_def", None) # Determine tool type by checking tool.toolset tool_type = "function" @@ -73,6 +74,7 @@ async def wrapped_execute_tool_call( args_dict, agent, tool_type=tool_type, + tool_definition=selected_tool_definition, ) as span: try: result = await original_execute_tool_call( @@ -127,6 +129,7 @@ async def wrapped_call_tool( # Extract tool info before calling original name = call.tool_name tool = self.tools.get(name) if self.tools else None + selected_tool_definition = getattr(tool, "tool_def", None) # Determine tool type by checking tool.toolset tool_type = "function" # default @@ -150,6 +153,7 @@ async def wrapped_call_tool( args_dict, agent, tool_type=tool_type, + tool_definition=selected_tool_definition, ) as span: try: result = await original_call_tool( diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index cc18302f87..4fcddc16dd 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -9,10 +9,15 @@ if TYPE_CHECKING: from typing import Any, Optional + from pydantic_ai._tool_manager import ToolDefinition # type: ignore def execute_tool_span( - tool_name: str, tool_args: "Any", agent: "Any", tool_type: str = "function" + tool_name: str, + tool_args: "Any", + agent: "Any", + tool_type: str = "function", + tool_definition: Optional["ToolDefinition"] = None, ) -> "sentry_sdk.tracing.Span": """Create a span for tool execution. @@ -21,6 +26,7 @@ def execute_tool_span( tool_args: The arguments passed to the tool agent: The agent executing the tool tool_type: The type of tool ("function" for regular tools, "mcp" for MCP services) + tool_definition: The definition of the tool, if available """ span = sentry_sdk.start_span( op=OP.GEN_AI_EXECUTE_TOOL, @@ -32,6 +38,9 @@ def execute_tool_span( span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type) span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + if tool_definition: + span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_definition.description) + _set_agent_data(span, agent) if _should_send_prompts() and tool_args is not None: diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index b0bde0301d..1d4202ac0b 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -12,12 +12,13 @@ from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages from sentry_sdk.integrations.pydantic_ai.spans.utils import _set_usage_data +from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span from pydantic_ai import Agent from pydantic_ai.messages import BinaryContent, UserPromptPart from pydantic_ai.usage import RequestUsage -from pydantic_ai.models.test import TestModel from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior +from pydantic_ai._tool_manager import ToolDefinition @pytest.fixture @@ -2794,3 +2795,142 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): (span_data,) = event["spans"] assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 80 assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20 + + +@pytest.mark.asyncio +async def test_tool_description_in_execute_tool_span(sentry_init, capture_events): + """ + Test that tool description from the tool's docstring is included in execute_tool spans. + """ + agent = Agent( + "test", + name="test_agent", + system_prompt="You are a helpful test assistant.", + ) + + @agent.tool_plain + def multiply_numbers(a: int, b: int) -> int: + """Multiply two numbers and return the product.""" + return a * b + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + events = capture_events() + + result = await agent.run("What is 5 times 3?") + assert result is not None + + (transaction,) = events + spans = transaction["spans"] + + tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"] + assert len(tool_spans) >= 1 + + tool_span = tool_spans[0] + assert tool_span["data"]["gen_ai.tool.name"] == "multiply_numbers" + assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"] + assert "Multiply two numbers" in tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] + + +@pytest.mark.asyncio +async def test_tool_without_description_omits_tool_description( + sentry_init, capture_events +): + """ + Test that execute_tool spans omit tool description when the tool has no docstring. + """ + agent = Agent( + "test", + name="test_agent", + system_prompt="You are a helpful test assistant.", + ) + + @agent.tool_plain + def no_docs_tool(a: int, b: int) -> int: + return a + b + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + events = capture_events() + + result = await agent.run("What is 5 + 3?") + assert result is not None + + (transaction,) = events + spans = transaction["spans"] + + tool_spans = [s for s in spans if s["op"] == "gen_ai.execute_tool"] + assert len(tool_spans) >= 1 + + tool_span = tool_spans[0] + assert tool_span["data"]["gen_ai.tool.name"] == "no_docs_tool" + assert SPANDATA.GEN_AI_TOOL_DESCRIPTION not in tool_span["data"] + + +@pytest.mark.asyncio +async def test_execute_tool_span_with_tool_definition(sentry_init, capture_events): + """ + Test execute_tool_span helper correctly sets tool description from a ToolDefinition. + """ + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + events = capture_events() + + tool_def = ToolDefinition( + name="my_tool", + description="A tool that does something useful.", + parameters_json_schema={"type": "object", "properties": {}}, + ) + + with sentry_sdk.start_transaction(op="test", name="test"): + with execute_tool_span( + "my_tool", {"arg": "value"}, None, "function", tool_definition=tool_def + ) as span: + assert span is not None + + (event,) = events + tool_spans = [s for s in event["spans"] if s["op"] == "gen_ai.execute_tool"] + assert len(tool_spans) == 1 + assert ( + tool_spans[0]["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] + == "A tool that does something useful." + ) + + +@pytest.mark.asyncio +async def test_execute_tool_span_without_tool_definition(sentry_init, capture_events): + """ + Test execute_tool_span helper omits tool description when tool_definition is None. + """ + + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + events = capture_events() + + with sentry_sdk.start_transaction(op="test", name="test"): + with execute_tool_span( + "my_tool", {"arg": "value"}, None, "function", tool_definition=None + ) as span: + assert span is not None + + (event,) = events + tool_spans = [s for s in event["spans"] if s["op"] == "gen_ai.execute_tool"] + assert len(tool_spans) == 1 + assert SPANDATA.GEN_AI_TOOL_DESCRIPTION not in tool_spans[0]["data"] From a136913fe7b3a8bb95d92a7982c15eae8f86b704 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 6 Mar 2026 07:51:12 -0500 Subject: [PATCH 2/7] ref(pydantic-ai): Fix type annotation style in execute_tool_span Move Optional inside quotes for consistency with type annotation style conventions. Co-Authored-By: Claude Haiku 4.5 --- sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index 4fcddc16dd..a8f0f74e31 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -17,7 +17,7 @@ def execute_tool_span( tool_args: "Any", agent: "Any", tool_type: str = "function", - tool_definition: Optional["ToolDefinition"] = None, + tool_definition: "Optional[ToolDefinition]" = None, ) -> "sentry_sdk.tracing.Span": """Create a span for tool execution. From a0bd0dc085c1bbff586448409715a60892e43f71 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 6 Mar 2026 09:23:02 -0500 Subject: [PATCH 3/7] test(pydantic-ai): Fix assertion for missing tool description When a tool has no description, the tool_description field should be present in the span data but have a None value. Update the assertion to check for field presence rather than field absence. Also use `is None` instead of `== None` for proper Python style. Co-Authored-By: Claude Haiku 4.5 --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 1d4202ac0b..2ea268febc 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -2872,7 +2872,8 @@ def no_docs_tool(a: int, b: int) -> int: tool_span = tool_spans[0] assert tool_span["data"]["gen_ai.tool.name"] == "no_docs_tool" - assert SPANDATA.GEN_AI_TOOL_DESCRIPTION not in tool_span["data"] + assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"] + assert tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] is None @pytest.mark.asyncio From b80dd979c46d94ea70bbd1c6a820c989309efedf Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 6 Mar 2026 09:38:57 -0500 Subject: [PATCH 4/7] fix(pydantic-ai): Safely access tool description attribute Use getattr with a None default when accessing tool_definition.description to prevent AttributeError if the attribute is missing. Co-Authored-By: Claude Haiku 4.5 --- sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index a8f0f74e31..ff28a98ade 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -39,7 +39,10 @@ def execute_tool_span( span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) if tool_definition: - span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_definition.description) + span.set_data( + SPANDATA.GEN_AI_TOOL_DESCRIPTION, + getattr(tool_definition, "description", None), + ) _set_agent_data(span, agent) From 46743ae8b85fe190827b0e72d7def663d2fe537c Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 6 Mar 2026 11:28:20 -0500 Subject: [PATCH 5/7] Address CR comments --- .../pydantic_ai/spans/execute_tool.py | 2 +- .../pydantic_ai/test_pydantic_ai.py | 66 ------------------- 2 files changed, 1 insertion(+), 67 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index ff28a98ade..487ffe3e6f 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -38,7 +38,7 @@ def execute_tool_span( span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type) span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) - if tool_definition: + if tool_definition is not None: span.set_data( SPANDATA.GEN_AI_TOOL_DESCRIPTION, getattr(tool_definition, "description", None), diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 2ea268febc..ec86446356 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -2361,7 +2361,6 @@ async def test_execute_tool_span_creation(sentry_init, capture_events): """ import sentry_sdk from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( - execute_tool_span, update_execute_tool_span, ) @@ -2387,7 +2386,6 @@ async def test_execute_tool_span_with_mcp_type(sentry_init, capture_events): Test execute_tool span with MCP tool type. """ import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span sentry_init( integrations=[PydanticAIIntegration()], @@ -2412,7 +2410,6 @@ async def test_execute_tool_span_without_prompts(sentry_init, capture_events): """ import sentry_sdk from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( - execute_tool_span, update_execute_tool_span, ) @@ -2438,7 +2435,6 @@ async def test_execute_tool_span_with_none_args(sentry_init, capture_events): Test execute_tool span with None args. """ import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span sentry_init( integrations=[PydanticAIIntegration()], @@ -2483,7 +2479,6 @@ async def test_update_execute_tool_span_with_none_result(sentry_init, capture_ev """ import sentry_sdk from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( - execute_tool_span, update_execute_tool_span, ) @@ -2874,64 +2869,3 @@ def no_docs_tool(a: int, b: int) -> int: assert tool_span["data"]["gen_ai.tool.name"] == "no_docs_tool" assert SPANDATA.GEN_AI_TOOL_DESCRIPTION in tool_span["data"] assert tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] is None - - -@pytest.mark.asyncio -async def test_execute_tool_span_with_tool_definition(sentry_init, capture_events): - """ - Test execute_tool_span helper correctly sets tool description from a ToolDefinition. - """ - - sentry_init( - integrations=[PydanticAIIntegration()], - traces_sample_rate=1.0, - send_default_pii=True, - ) - - events = capture_events() - - tool_def = ToolDefinition( - name="my_tool", - description="A tool that does something useful.", - parameters_json_schema={"type": "object", "properties": {}}, - ) - - with sentry_sdk.start_transaction(op="test", name="test"): - with execute_tool_span( - "my_tool", {"arg": "value"}, None, "function", tool_definition=tool_def - ) as span: - assert span is not None - - (event,) = events - tool_spans = [s for s in event["spans"] if s["op"] == "gen_ai.execute_tool"] - assert len(tool_spans) == 1 - assert ( - tool_spans[0]["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] - == "A tool that does something useful." - ) - - -@pytest.mark.asyncio -async def test_execute_tool_span_without_tool_definition(sentry_init, capture_events): - """ - Test execute_tool_span helper omits tool description when tool_definition is None. - """ - - sentry_init( - integrations=[PydanticAIIntegration()], - traces_sample_rate=1.0, - send_default_pii=True, - ) - - events = capture_events() - - with sentry_sdk.start_transaction(op="test", name="test"): - with execute_tool_span( - "my_tool", {"arg": "value"}, None, "function", tool_definition=None - ) as span: - assert span is not None - - (event,) = events - tool_spans = [s for s in event["spans"] if s["op"] == "gen_ai.execute_tool"] - assert len(tool_spans) == 1 - assert SPANDATA.GEN_AI_TOOL_DESCRIPTION not in tool_spans[0]["data"] From bf8528548082a2354dbd54ac545756fb05e7bd1a Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 6 Mar 2026 11:47:55 -0500 Subject: [PATCH 6/7] cleanups --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index ec86446356..3501bc6589 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -18,7 +18,6 @@ from pydantic_ai.messages import BinaryContent, UserPromptPart from pydantic_ai.usage import RequestUsage from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior -from pydantic_ai._tool_manager import ToolDefinition @pytest.fixture @@ -2832,11 +2831,11 @@ def multiply_numbers(a: int, b: int) -> int: @pytest.mark.asyncio -async def test_tool_without_description_omits_tool_description( +async def test_tool_without_description_sets_tool_description_to_none( sentry_init, capture_events ): """ - Test that execute_tool spans omit tool description when the tool has no docstring. + Test that execute_tool spans set tool description to None when the tool has no docstring. """ agent = Agent( "test", From 7a7f2bc774ceeec4765ca6b4e84b7ff7f43ce712 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 6 Mar 2026 14:40:10 -0500 Subject: [PATCH 7/7] changes to make warden happy --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 3501bc6589..9843adc542 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -12,7 +12,6 @@ from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages from sentry_sdk.integrations.pydantic_ai.spans.utils import _set_usage_data -from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span from pydantic_ai import Agent from pydantic_ai.messages import BinaryContent, UserPromptPart @@ -2360,6 +2359,7 @@ async def test_execute_tool_span_creation(sentry_init, capture_events): """ import sentry_sdk from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( + execute_tool_span, update_execute_tool_span, ) @@ -2385,6 +2385,9 @@ async def test_execute_tool_span_with_mcp_type(sentry_init, capture_events): Test execute_tool span with MCP tool type. """ import sentry_sdk + from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( + execute_tool_span, + ) sentry_init( integrations=[PydanticAIIntegration()], @@ -2409,6 +2412,7 @@ async def test_execute_tool_span_without_prompts(sentry_init, capture_events): """ import sentry_sdk from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( + execute_tool_span, update_execute_tool_span, ) @@ -2434,6 +2438,7 @@ async def test_execute_tool_span_with_none_args(sentry_init, capture_events): Test execute_tool span with None args. """ import sentry_sdk + from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import execute_tool_span sentry_init( integrations=[PydanticAIIntegration()], @@ -2478,6 +2483,7 @@ async def test_update_execute_tool_span_with_none_result(sentry_init, capture_ev """ import sentry_sdk from sentry_sdk.integrations.pydantic_ai.spans.execute_tool import ( + execute_tool_span, update_execute_tool_span, )