diff --git a/sentry_sdk/integrations/pydantic_ai/consts.py b/sentry_sdk/integrations/pydantic_ai/consts.py index afa66dc47d..9f96ea0adf 100644 --- a/sentry_sdk/integrations/pydantic_ai/consts.py +++ b/sentry_sdk/integrations/pydantic_ai/consts.py @@ -1 +1,9 @@ +import re + SPAN_ORIGIN = "auto.ai.pydantic_ai" + +# Matches data URLs with base64-encoded content, e.g. "data:image/png;base64,iVBORw0K..." +# Group 1: MIME type (e.g. "image/png"), Group 2: base64 data +DATA_URL_BASE64_REGEX = re.compile( + r"^data:([a-zA-Z]+/[a-zA-Z]+);base64,([A-Za-z0-9+/\-_]+={0,2})$" +) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/tools.py b/sentry_sdk/integrations/pydantic_ai/patches/tools.py index 1f5cde8742..40e646019c 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/tools.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/tools.py @@ -50,13 +50,15 @@ 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" if tool and HAS_MCP and isinstance(tool.toolset, MCPServer): tool_type = "mcp" + tool_def = getattr(tool, "tool_def", None) + tool_description = getattr(tool_def, "description", None) + # Get agent from contextvar agent = get_current_agent() @@ -74,7 +76,7 @@ async def wrapped_execute_tool_call( args_dict, agent, tool_type=tool_type, - tool_definition=selected_tool_definition, + tool_description=tool_description, ) as span: try: result = await original_execute_tool_call( @@ -129,13 +131,15 @@ 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 if tool and HAS_MCP and isinstance(tool.toolset, MCPServer): tool_type = "mcp" + tool_def = getattr(tool, "tool_def", None) + tool_description = getattr(tool_def, "description", None) + # Get agent from contextvar agent = get_current_agent() @@ -153,7 +157,7 @@ async def wrapped_call_tool( args_dict, agent, tool_type=tool_type, - tool_definition=selected_tool_definition, + tool_description=tool_description, ) as span: try: result = await original_call_tool( diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index b5ce15e99e..8e0b6b9f35 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -1,12 +1,10 @@ import json import sentry_sdk -from sentry_sdk._types import BLOB_DATA_SUBSTITUTE from sentry_sdk.ai.utils import ( normalize_message_roles, set_data_normalized, truncate_and_annotate_messages, - get_modality_from_mime_type, ) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.utils import safe_serialize @@ -21,7 +19,11 @@ get_current_agent, get_is_streaming, ) -from .utils import _set_usage_data +from .utils import ( + _serialize_binary_content_item, + _serialize_image_url_item, + _set_usage_data, +) from typing import TYPE_CHECKING @@ -40,6 +42,7 @@ TextPart, ThinkingPart, BinaryContent, + ImageUrl, ) except ImportError: # Fallback if these classes are not available @@ -50,6 +53,7 @@ TextPart = None ThinkingPart = None BinaryContent = None + ImageUrl = None def _transform_system_instructions( @@ -158,22 +162,14 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non for item in part.content: if isinstance(item, str): 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): - content.append( - { - "type": "blob", - "modality": get_modality_from_mime_type( - item.media_type - ), - "mime_type": item.media_type, - "content": BLOB_DATA_SUBSTITUTE, - } - ) + content.append(_serialize_binary_content_item(item)) else: content.append(safe_serialize(item)) else: 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/execute_tool.py b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py index 83ce6819e9..6c70ff9a44 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py @@ -9,7 +9,6 @@ if TYPE_CHECKING: from typing import Any, Optional - from pydantic_ai._tool_manager import ToolDefinition # type: ignore def execute_tool_span( @@ -17,7 +16,7 @@ def execute_tool_span( tool_args: "Any", agent: "Any", tool_type: str = "function", - tool_definition: "Optional[ToolDefinition]" = None, + tool_description: "Optional[str]" = None, ) -> "sentry_sdk.tracing.Span": """Create a span for tool execution. @@ -26,7 +25,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 + tool_description: Optional description of the tool """ span = sentry_sdk.start_span( op=OP.GEN_AI_EXECUTE_TOOL, @@ -38,11 +37,8 @@ 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 is not None and hasattr(tool_definition, "description"): - span.set_data( - SPANDATA.GEN_AI_TOOL_DESCRIPTION, - tool_definition.description, - ) + if tool_description is not None: + span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_description) _set_agent_data(span, agent) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index b4f8307170..ee08ca7036 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -1,7 +1,5 @@ import sentry_sdk -from sentry_sdk._types import BLOB_DATA_SUBSTITUTE from sentry_sdk.ai.utils import ( - get_modality_from_mime_type, get_start_span_function, normalize_message_roles, set_data_normalized, @@ -16,7 +14,11 @@ _set_model_data, _should_send_prompts, ) -from .utils import _set_usage_data +from .utils import ( + _serialize_binary_content_item, + _serialize_image_url_item, + _set_usage_data, +) from typing import TYPE_CHECKING @@ -24,9 +26,10 @@ from typing import Any try: - from pydantic_ai.messages import BinaryContent # type: ignore + from pydantic_ai.messages import BinaryContent, ImageUrl # type: ignore except ImportError: BinaryContent = None + ImageUrl = None def invoke_agent_span( @@ -105,17 +108,10 @@ def invoke_agent_span( for item in user_prompt: if isinstance(item, str): 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): - content.append( - { - "type": "blob", - "modality": get_modality_from_mime_type( - item.media_type - ), - "mime_type": item.media_type, - "content": BLOB_DATA_SUBSTITUTE, - } - ) + content.append(_serialize_binary_content_item(item)) if content: messages.append( { diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py index 4a8ad4c68c..012c932a5c 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -1,14 +1,56 @@ """Utility functions for PydanticAI span instrumentation.""" import sentry_sdk +from sentry_sdk._types import BLOB_DATA_SUBSTITUTE +from sentry_sdk.ai.utils import get_modality_from_mime_type from sentry_sdk.consts import SPANDATA +from ..consts import DATA_URL_BASE64_REGEX + from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Union, Dict, Any, List + from typing import Union, Dict, Any, List, Optional from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore +try: + from pydantic_ai.messages import BinaryContent, ImageUrl # type: ignore +except ImportError: + BinaryContent = None + ImageUrl = None + + +def _serialize_image_url_item(item: "Any") -> "Dict[str, Any]": + """Serialize an ImageUrl content item for span data. + + For data URLs containing base64-encoded images, the content is redacted. + For regular HTTP URLs, the URL string is preserved. + """ + data_url_matches = DATA_URL_BASE64_REGEX.match(item.url) + + if data_url_matches: + mime_type = data_url_matches[1] or "image" + return { + "type": "image", + "mime_type": mime_type, + "content": BLOB_DATA_SUBSTITUTE, + } + + return { + "type": "image", + "content": str(item.url), + } + + +def _serialize_binary_content_item(item: "Any") -> "Dict[str, Any]": + """Serialize a BinaryContent item for span data, redacting the blob data.""" + return { + "type": "blob", + "modality": get_modality_from_mime_type(item.media_type), + "mime_type": item.media_type, + "content": BLOB_DATA_SUBSTITUTE, + } + def _set_usage_data( span: "sentry_sdk.tracing.Span", usage: "Union[RequestUsage, RunUsage]"