From 465aab0729bd4feefb9ed7912c3d3ddf4638070b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:21:42 +0200 Subject: [PATCH 01/33] test(pydantic-ai): Consolidate binary blob redaction tests to stop relying on SDK internals --- .../pydantic_ai/test_pydantic_ai.py | 67 +------------------ 1 file changed, 2 insertions(+), 65 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index f0ddc6c4ed..b1efe26bf2 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -2794,8 +2794,9 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20 +@pytest.mark.asyncio @pytest.mark.parametrize( - "url,image_url_kwargs,expected_content", + "url, image_url_kwargs, expected_content", [ pytest.param( "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", @@ -2833,76 +2834,12 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): BLOB_DATA_SUBSTITUTE, id="multiple_optional_parameters", ), - ], -) -def test_image_url_base64_content_in_span( - sentry_init, capture_events, url, image_url_kwargs, expected_content -): - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import ai_client_span - - 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"): - image_url = ImageUrl(url=url, **image_url_kwargs) - user_part = UserPromptPart(content=["Look at this image:", image_url]) - mock_msg = MagicMock() - mock_msg.parts = [user_part] - mock_msg.instructions = None - - span = ai_client_span([mock_msg], None, None, None) - span.finish() - - (event,) = events - chat_spans = [s for s in event["spans"] if s["op"] == "gen_ai.chat"] - assert len(chat_spans) >= 1 - messages_data = _get_messages_from_span(chat_spans[0]["data"]) - - found_image = False - for msg in messages_data: - if "content" not in msg: - continue - for content_item in msg["content"]: - if content_item.get("type") == "image": - found_image = True - assert content_item["content"] == expected_content - - assert found_image, "Image content item should be found in messages data" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "url, image_url_kwargs, expected_content", - [ pytest.param( "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", {}, BLOB_DATA_SUBSTITUTE, id="base64_data_url_redacted", ), - pytest.param( - "https://example.com/image.png", - {}, - "https://example.com/image.png", - id="http_url_no_redaction", - ), - pytest.param( - "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", - {}, - "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", - id="http_url_with_base64_query_param", - ), - pytest.param( - "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", - {"media_type": "image/png"}, - "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", - id="http_url_with_base64_query_param_and_media_type", - ), ], ) async def test_invoke_agent_image_url( From f590e3236d90b35b5e6efc060fded870d6012a0c Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:22:49 +0200 Subject: [PATCH 02/33] . --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index b1efe26bf2..3e1e6c70e3 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -2834,12 +2834,6 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): BLOB_DATA_SUBSTITUTE, id="multiple_optional_parameters", ), - pytest.param( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", - {}, - BLOB_DATA_SUBSTITUTE, - id="base64_data_url_redacted", - ), ], ) async def test_invoke_agent_image_url( From d95d78dd13b39cc8f23eb167dc48c655bb455a2d Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:29:07 +0200 Subject: [PATCH 03/33] fix(pydantic-ai): Set system instructions after instructions are set on the request object --- .../pydantic_ai/patches/_agent_run.py | 278 ++++++++++++++++++ .../pydantic_ai/patches/graph_nodes.py | 11 +- .../pydantic_ai/patches/model_request.py | 7 +- .../pydantic_ai/spans/ai_client.py | 167 +---------- .../pydantic_ai/spans/invoke_agent.py | 4 +- .../integrations/pydantic_ai/spans/utils.py | 37 +-- sentry_sdk/integrations/pydantic_ai/utils.py | 208 ++++++++++++- .../pydantic_ai/test_pydantic_ai.py | 21 +- 8 files changed, 513 insertions(+), 220 deletions(-) create mode 100644 sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py diff --git a/sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py new file mode 100644 index 0000000000..c6e66d42dc --- /dev/null +++ b/sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py @@ -0,0 +1,278 @@ +import sys +import itertools +from collections.abc import Sequence +from functools import wraps + +import sentry_sdk +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.pydantic_ai.utils import _should_send_prompts +from sentry_sdk.utils import capture_internal_exceptions, reraise +from sentry_sdk.consts import SPANDATA +from sentry_sdk.ai import set_data_normalized + +from ..spans import invoke_agent_span, update_invoke_agent_span +from ..utils import _capture_exception, pop_agent, push_agent + +from typing import TYPE_CHECKING + +try: + from pydantic_ai.agent import Agent # type: ignore +except ImportError: + raise DidNotEnable("pydantic-ai not installed") + +if TYPE_CHECKING: + from typing import Any, Callable, Optional, Union + + from pydantic_ai.agent.abstract import Instructions + from pydantic_ai.tools import SystemPromptFunc + + from sentry_sdk._types import TextPart + + +def _get_current_system_instructions( + agent_creation_time_instructions: "list[Union[str, SystemPromptFunc]]", + system_instructions: "Optional[Instructions[Any]]", +) -> "list[str]": + instruction_texts: "list[str]" = [] + + instruction_texts += [ + instruction + for instruction in agent_creation_time_instructions + if isinstance(instruction, str) + ] + + if isinstance(system_instructions, str): + instruction_texts.append(system_instructions) + + elif isinstance(system_instructions, Sequence): + instruction_texts += [ + instruction + for instruction in system_instructions + if isinstance(instruction, str) + ] + + return instruction_texts + + +def _transform_system_instructions( + permanent_instructions: "list[str]", current_instructions: "list[str]" +) -> "list[TextPart]": + return [ + { + "type": "text", + "content": instruction, + } + for instruction in itertools.chain(permanent_instructions, current_instructions) + ] + + +class _StreamingContextManagerWrapper: + """Wrapper for streaming methods that return async context managers.""" + + def __init__( + self, + agent: "Any", + original_ctx_manager: "Any", + user_prompt: "Any", + model: "Any", + model_settings: "Any", + is_streaming: bool = True, + ) -> None: + self.agent = agent + self.original_ctx_manager = original_ctx_manager + self.user_prompt = user_prompt + self.model = model + self.model_settings = model_settings + self.is_streaming = is_streaming + self._isolation_scope: "Any" = None + self._span: "Optional[sentry_sdk.tracing.Span]" = None + self._result: "Any" = None + + async def __aenter__(self) -> "Any": + # Set up isolation scope and invoke_agent span + self._isolation_scope = sentry_sdk.isolation_scope() + self._isolation_scope.__enter__() + + # Create invoke_agent span (will be closed in __aexit__) + self._span = invoke_agent_span( + self.user_prompt, + self.agent, + self.model, + self.model_settings, + self.is_streaming, + ) + self._span.__enter__() + + # Push agent to contextvar stack after span is successfully created and entered + # This ensures proper pairing with pop_agent() in __aexit__ even if exceptions occur + push_agent(self.agent, self.is_streaming) + + # Enter the original context manager + result = await self.original_ctx_manager.__aenter__() + self._result = result + return result + + async def __aexit__(self, exc_type: "Any", exc_val: "Any", exc_tb: "Any") -> None: + try: + # Exit the original context manager first + await self.original_ctx_manager.__aexit__(exc_type, exc_val, exc_tb) + + # Update span with result if successful + if exc_type is None and self._result and self._span is not None: + update_invoke_agent_span(self._span, self._result) + finally: + # Pop agent from contextvar stack + pop_agent() + + # Clean up invoke span + if self._span: + self._span.__exit__(exc_type, exc_val, exc_tb) + + # Clean up isolation scope + if self._isolation_scope: + self._isolation_scope.__exit__(exc_type, exc_val, exc_tb) + + +def _create_run_wrapper( + original_func: "Callable[..., Any]", is_streaming: bool = False +) -> "Callable[..., Any]": + """ + Wraps the Agent.run method to create an invoke_agent span. + + Args: + original_func: The original run method + is_streaming: Whether this is a streaming method (for future use) + """ + + @wraps(original_func) + async def wrapper(self: "Agent", *args: "Any", **kwargs: "Any") -> "Any": + # Isolate each workflow so that when agents are run in asyncio tasks they + # don't touch each other's scopes + with sentry_sdk.isolation_scope(): + # Extract parameters for the span + user_prompt = kwargs.get("user_prompt") or (args[0] if args else None) + model = kwargs.get("model") + model_settings = kwargs.get("model_settings") + + permanent_instructions = list(self._system_prompts) + + instructions: "Optional[Instructions[Any]]" = kwargs.get("instructions") + current_instructions = _get_current_system_instructions( + self._instructions, instructions + ) + + # Create invoke_agent span + with invoke_agent_span( + user_prompt, self, model, model_settings, is_streaming + ) as span: + if _should_send_prompts() and ( + len(permanent_instructions) > 0 or len(current_instructions) > 0 + ): + set_data_normalized( + span, + SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, + _transform_system_instructions( + permanent_instructions, current_instructions + ), + unpack=False, + ) + + # Push agent to contextvar stack after span is successfully created and entered + # This ensures proper pairing with pop_agent() in finally even if exceptions occur + push_agent(self, is_streaming) + + try: + result = await original_func(self, *args, **kwargs) + + # Update span with result + update_invoke_agent_span(span, result) + + return result + except Exception as exc: + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) + reraise(*exc_info) + finally: + # Pop agent from contextvar stack + pop_agent() + + return wrapper + + +def _create_streaming_wrapper( + original_func: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Wraps run_stream method that returns an async context manager. + """ + + @wraps(original_func) + def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + # Extract parameters for the span + user_prompt = kwargs.get("user_prompt") or (args[0] if args else None) + model = kwargs.get("model") + model_settings = kwargs.get("model_settings") + + # Call original function to get the context manager + original_ctx_manager = original_func(self, *args, **kwargs) + + # Wrap it with our instrumentation + return _StreamingContextManagerWrapper( + agent=self, + original_ctx_manager=original_ctx_manager, + user_prompt=user_prompt, + model=model, + model_settings=model_settings, + is_streaming=True, + ) + + return wrapper + + +def _create_streaming_events_wrapper( + original_func: "Callable[..., Any]", +) -> "Callable[..., Any]": + """ + Wraps run_stream_events method - no span needed as it delegates to run(). + + Note: run_stream_events internally calls self.run() with an event_stream_handler, + so the invoke_agent span will be created by the run() wrapper. + """ + + @wraps(original_func) + async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": + # Just call the original generator - it will call run() which has the instrumentation + try: + async for event in original_func(self, *args, **kwargs): + yield event + except Exception as exc: + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) + reraise(*exc_info) + + return wrapper + + +def _patch_agent_run() -> None: + """ + Patches the Agent run methods to create spans for agent execution. + + This patches both non-streaming (run, run_sync) and streaming + (run_stream, run_stream_events) methods. + """ + + # Store original methods + original_run = Agent.run + original_run_stream = Agent.run_stream + original_run_stream_events = Agent.run_stream_events + + # Wrap and apply patches for non-streaming methods + Agent.run = _create_run_wrapper(original_run, is_streaming=False) + + # Wrap and apply patches for streaming methods + Agent.run_stream = _create_streaming_wrapper(original_run_stream) + Agent.run_stream_events = _create_streaming_events_wrapper( + original_run_stream_events + ) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index afb10395f4..70b1a68b4b 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -7,6 +7,7 @@ ai_client_span, update_ai_client_span, ) +from ..utils import _set_input_messages try: from pydantic_ai._agent_graph import ModelRequestNode # type: ignore @@ -59,9 +60,12 @@ def _patch_graph_nodes() -> None: async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any": messages, model, model_settings = _extract_span_data(self, ctx) - with ai_client_span(messages, None, model, model_settings) as span: + with ai_client_span(None, model, model_settings) as span: result = await original_model_request_run(self, ctx) + if messages: + _set_input_messages(span, messages) + # Extract response from result if available model_response = None if hasattr(result, "model_response"): @@ -86,7 +90,10 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any": messages, model, model_settings = _extract_span_data(self, ctx) # Create chat span for streaming request - with ai_client_span(messages, None, model, model_settings) as span: + with ai_client_span(None, model, model_settings) as span: + if messages: + _set_input_messages(span, messages) + # Call the original stream method async with original_stream_method(self, ctx) as stream: yield stream diff --git a/sentry_sdk/integrations/pydantic_ai/patches/model_request.py b/sentry_sdk/integrations/pydantic_ai/patches/model_request.py index 94a96161f3..e7823c50c6 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/model_request.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/model_request.py @@ -3,6 +3,8 @@ from sentry_sdk.integrations import DidNotEnable +from ..utils import _set_input_messages + try: from pydantic_ai import models # type: ignore except ImportError: @@ -32,7 +34,10 @@ async def wrapped_request( self: "Any", messages: "Any", *args: "Any", **kwargs: "Any" ) -> "Any": # Pass all messages (full conversation history) - with ai_client_span(messages, None, self, None) as span: + with ai_client_span(None, self, None) as span: + if messages: + _set_input_messages(span, messages) + result = await original_request(self, messages, *args, **kwargs) update_ai_client_span(span, result) return result diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index dc95acad45..2050ee6198 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -1,10 +1,6 @@ -import json - import sentry_sdk from sentry_sdk.ai.utils import ( - normalize_message_roles, set_data_normalized, - truncate_and_annotate_messages, ) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.utils import safe_serialize @@ -20,178 +16,23 @@ get_is_streaming, ) from .utils import ( - _serialize_binary_content_item, - _serialize_image_url_item, _set_usage_data, ) from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, List, Dict - from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore - from sentry_sdk._types import TextPart as SentryTextPart + from typing import Any try: from pydantic_ai.messages import ( BaseToolCallPart, - BaseToolReturnPart, - SystemPromptPart, - UserPromptPart, TextPart, - ThinkingPart, - BinaryContent, - ImageUrl, ) except ImportError: # Fallback if these classes are not available BaseToolCallPart = None - BaseToolReturnPart = None - SystemPromptPart = None - UserPromptPart = None TextPart = None - ThinkingPart = None - BinaryContent = None - ImageUrl = None - - -def _transform_system_instructions( - permanent_instructions: "list[SystemPromptPart]", - current_instructions: "list[str]", -) -> "list[SentryTextPart]": - text_parts: "list[SentryTextPart]" = [ - { - "type": "text", - "content": instruction.content, - } - for instruction in permanent_instructions - ] - - text_parts.extend( - { - "type": "text", - "content": instruction, - } - for instruction in current_instructions - ) - - return text_parts - - -def _get_system_instructions( - messages: "list[ModelMessage]", -) -> "tuple[list[SystemPromptPart], list[str]]": - permanent_instructions = [] - current_instructions = [] - - for msg in messages: - if hasattr(msg, "parts"): - for part in msg.parts: - if SystemPromptPart and isinstance(part, SystemPromptPart): - permanent_instructions.append(part) - - if hasattr(msg, "instructions") and msg.instructions is not None: - current_instructions.append(msg.instructions) - - return permanent_instructions, current_instructions - - -def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: - """Set input messages data on a span.""" - if not _should_send_prompts(): - return - - if not messages: - return - - permanent_instructions, current_instructions = _get_system_instructions(messages) - if len(permanent_instructions) > 0 or len(current_instructions) > 0: - span.set_data( - SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, - json.dumps( - _transform_system_instructions( - permanent_instructions, current_instructions - ) - ), - ) - - try: - formatted_messages = [] - - for msg in messages: - if hasattr(msg, "parts"): - for part in msg.parts: - role = "user" - # Use isinstance checks with proper base classes - if SystemPromptPart and isinstance(part, SystemPromptPart): - continue - elif ( - (TextPart and isinstance(part, TextPart)) - or (ThinkingPart and isinstance(part, ThinkingPart)) - or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) - ): - role = "assistant" - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): - role = "tool" - - content: "List[Dict[str, Any] | str]" = [] - tool_calls = None - tool_call_id = None - - # Handle ToolCallPart (assistant requesting tool use) - if BaseToolCallPart and isinstance(part, BaseToolCallPart): - tool_call_data = {} - if hasattr(part, "tool_name"): - tool_call_data["name"] = part.tool_name - if hasattr(part, "args"): - tool_call_data["arguments"] = safe_serialize(part.args) - if tool_call_data: - tool_calls = [tool_call_data] - # Handle ToolReturnPart (tool result) - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): - 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 - elif hasattr(part, "content"): - if isinstance(part.content, str): - 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", "text": item}) - elif ImageUrl and isinstance(item, ImageUrl): - content.append(_serialize_image_url_item(item)) - elif BinaryContent and isinstance(item, BinaryContent): - 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} - if content: - message["content"] = content - if tool_calls: - message["tool_calls"] = tool_calls - if tool_call_id: - message["tool_call_id"] = tool_call_id - formatted_messages.append(message) - - if formatted_messages: - normalized_messages = normalize_message_roles(formatted_messages) - scope = sentry_sdk.get_current_scope() - messages_data = truncate_and_annotate_messages( - normalized_messages, span, scope - ) - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False - ) - except Exception: - # If we fail to format messages, just skip it - pass def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None: @@ -236,7 +77,7 @@ def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None: def ai_client_span( - messages: "Any", agent: "Any", model: "Any", model_settings: "Any" + agent: "Any", model: "Any", model_settings: "Any" ) -> "sentry_sdk.tracing.Span": """Create a span for an AI client call (model request). @@ -271,10 +112,6 @@ def ai_client_span( agent_obj = agent or get_current_agent() _set_available_tools(span, agent_obj) - # Set input messages (full conversation history) - if messages: - _set_input_messages(span, messages) - return span diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index ee08ca7036..7db60b5c7f 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -13,10 +13,10 @@ _set_available_tools, _set_model_data, _should_send_prompts, -) -from .utils import ( _serialize_binary_content_item, _serialize_image_url_item, +) +from .utils import ( _set_usage_data, ) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py index 70e47dc034..e5538eba3c 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -1,50 +1,15 @@ """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 + from typing import Union from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore -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. - """ - url = str(item.url) - data_url_match = DATA_URL_BASE64_REGEX.match(url) - - if data_url_match: - return { - "type": "image", - "content": BLOB_DATA_SUBSTITUTE, - } - - return { - "type": "image", - "content": 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]" ) -> None: diff --git a/sentry_sdk/integrations/pydantic_ai/utils.py b/sentry_sdk/integrations/pydantic_ai/utils.py index 62d36fb912..424acab110 100644 --- a/sentry_sdk/integrations/pydantic_ai/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/utils.py @@ -1,3 +1,5 @@ +import json + import sentry_sdk from contextvars import ContextVar from sentry_sdk.consts import SPANDATA @@ -5,10 +7,44 @@ from sentry_sdk.tracing_utils import set_span_errored from sentry_sdk.utils import event_from_exception, safe_serialize +from sentry_sdk.ai.utils import ( + normalize_message_roles, + set_data_normalized, + truncate_and_annotate_messages, +) +from sentry_sdk._types import BLOB_DATA_SUBSTITUTE +from sentry_sdk.ai.utils import get_modality_from_mime_type + + +from .consts import DATA_URL_BASE64_REGEX + from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Optional + from typing import Any, Optional, List, Dict + from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore + from sentry_sdk._types import TextPart as SentryTextPart + + +try: + from pydantic_ai.messages import ( + BaseToolCallPart, + BaseToolReturnPart, + SystemPromptPart, + TextPart, + ThinkingPart, + BinaryContent, + ImageUrl, + ) +except ImportError: + # Fallback if these classes are not available + BaseToolCallPart = None + BaseToolReturnPart = None + SystemPromptPart = None + TextPart = None + ThinkingPart = None + BinaryContent = None + ImageUrl = None # Store the current agent context in a contextvar for re-entrant safety @@ -215,3 +251,173 @@ def _capture_exception(exc: "Any", handled: bool = False) -> None: mechanism={"type": "pydantic_ai", "handled": handled}, ) sentry_sdk.capture_event(event, hint=hint) + + +def _transform_system_instructions( + permanent_instructions: "list[SystemPromptPart]", + current_instructions: "list[str]", +) -> "list[SentryTextPart]": + text_parts: "list[SentryTextPart]" = [ + { + "type": "text", + "content": instruction.content, + } + for instruction in permanent_instructions + ] + + text_parts.extend( + { + "type": "text", + "content": instruction, + } + for instruction in current_instructions + ) + + return text_parts + + +def _get_system_instructions( + messages: "list[ModelMessage]", +) -> "tuple[list[SystemPromptPart], list[str]]": + permanent_instructions = [] + current_instructions = [] + + for msg in messages: + if hasattr(msg, "parts"): + for part in msg.parts: + if SystemPromptPart and isinstance(part, SystemPromptPart): + permanent_instructions.append(part) + + if hasattr(msg, "instructions") and msg.instructions is not None: + current_instructions.append(msg.instructions) + + return permanent_instructions, current_instructions + + +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. + """ + url = str(item.url) + data_url_match = DATA_URL_BASE64_REGEX.match(url) + + if data_url_match: + return { + "type": "image", + "content": BLOB_DATA_SUBSTITUTE, + } + + return { + "type": "image", + "content": 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_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: + """Set input messages data on a span.""" + if not _should_send_prompts(): + return + + if not messages: + return + + permanent_instructions, current_instructions = _get_system_instructions(messages) + if len(permanent_instructions) > 0 or len(current_instructions) > 0: + span.set_data( + SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, + json.dumps( + _transform_system_instructions( + permanent_instructions, current_instructions + ) + ), + ) + + try: + formatted_messages = [] + + for msg in messages: + if hasattr(msg, "parts"): + for part in msg.parts: + role = "user" + # Use isinstance checks with proper base classes + if SystemPromptPart and isinstance(part, SystemPromptPart): + continue + elif ( + (TextPart and isinstance(part, TextPart)) + or (ThinkingPart and isinstance(part, ThinkingPart)) + or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) + ): + role = "assistant" + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + role = "tool" + + content: "List[Dict[str, Any] | str]" = [] + tool_calls = None + tool_call_id = None + + # Handle ToolCallPart (assistant requesting tool use) + if BaseToolCallPart and isinstance(part, BaseToolCallPart): + tool_call_data = {} + if hasattr(part, "tool_name"): + tool_call_data["name"] = part.tool_name + if hasattr(part, "args"): + tool_call_data["arguments"] = safe_serialize(part.args) + if tool_call_data: + tool_calls = [tool_call_data] + # Handle ToolReturnPart (tool result) + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + 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 + elif hasattr(part, "content"): + if isinstance(part.content, str): + 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", "text": item}) + elif ImageUrl and isinstance(item, ImageUrl): + content.append(_serialize_image_url_item(item)) + elif BinaryContent and isinstance(item, BinaryContent): + 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} + if content: + message["content"] = content + if tool_calls: + message["tool_calls"] = tool_calls + if tool_call_id: + message["tool_call_id"] = tool_call_id + formatted_messages.append(message) + + if formatted_messages: + normalized_messages = normalize_message_roles(formatted_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) + except Exception: + # If we fail to format messages, just skip it + pass diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 3e1e6c70e3..0280893828 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -10,7 +10,7 @@ from sentry_sdk._types import BLOB_DATA_SUBSTITUTE from sentry_sdk.consts import SPANDATA 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.utils import _set_input_messages from sentry_sdk.integrations.pydantic_ai.spans.utils import _set_usage_data from pydantic_ai import Agent from pydantic_ai.messages import BinaryContent, ImageUrl, UserPromptPart @@ -1221,12 +1221,9 @@ async def test_invoke_agent_with_instructions( agent = Agent( "test", name="test_instructions", + system_prompt="System prompt", ) - # Add instructions via _instructions attribute (internal API) - agent._instructions = ["Instruction 1", "Instruction 2"] - agent._system_prompts = ["System prompt"] - sentry_init( integrations=[PydanticAIIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, @@ -1235,7 +1232,10 @@ async def test_invoke_agent_with_instructions( events = capture_events() - await agent.run("Test input") + await agent.run( + "Test input", + instructions=["Instruction 1", "Instruction 2"], + ) (transaction,) = events spans = transaction["spans"] @@ -1637,7 +1637,6 @@ async def test_input_messages_error_handling(sentry_init, capture_events): Test that _set_input_messages handles errors gracefully. """ import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages sentry_init( integrations=[PydanticAIIntegration()], @@ -1791,7 +1790,6 @@ async def test_message_parts_with_list_content(sentry_init, capture_events): """ import sentry_sdk from unittest.mock import MagicMock - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages sentry_init( integrations=[PydanticAIIntegration()], @@ -1898,7 +1896,6 @@ async def test_message_with_system_prompt_part(sentry_init, capture_events): """ import sentry_sdk from unittest.mock import MagicMock - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages from pydantic_ai import messages sentry_init( @@ -1935,7 +1932,6 @@ async def test_message_with_instructions(sentry_init, capture_events): """ import sentry_sdk from unittest.mock import MagicMock - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages sentry_init( integrations=[PydanticAIIntegration()], @@ -1970,7 +1966,6 @@ async def test_set_input_messages_without_prompts(sentry_init, capture_events): Test that _set_input_messages respects _should_send_prompts(). """ import sentry_sdk - from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages sentry_init( integrations=[PydanticAIIntegration(include_prompts=False)], @@ -2612,7 +2607,7 @@ async def test_ai_client_span_with_streaming_flag(sentry_init, capture_events): scope._contexts["pydantic_ai_agent"] = {"_streaming": True} # Create ai_client span - span = ai_client_span([], None, None, None) + span = ai_client_span(None, None, None) span.finish() # Should not crash @@ -2643,7 +2638,7 @@ async def test_ai_client_span_gets_agent_from_scope(sentry_init, capture_events) scope._contexts["pydantic_ai_agent"] = {"_agent": mock_agent} # Create ai_client span without passing agent - span = ai_client_span([], None, None, None) + span = ai_client_span(None, None, None) span.finish() # Should not crash From de56c78cc9f4f1f2895843ed046afeea8abbe739 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:30:23 +0200 Subject: [PATCH 04/33] . --- .../pydantic_ai/patches/_agent_run.py | 278 ------------------ 1 file changed, 278 deletions(-) delete mode 100644 sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py diff --git a/sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py deleted file mode 100644 index c6e66d42dc..0000000000 --- a/sentry_sdk/integrations/pydantic_ai/patches/_agent_run.py +++ /dev/null @@ -1,278 +0,0 @@ -import sys -import itertools -from collections.abc import Sequence -from functools import wraps - -import sentry_sdk -from sentry_sdk.integrations import DidNotEnable -from sentry_sdk.integrations.pydantic_ai.utils import _should_send_prompts -from sentry_sdk.utils import capture_internal_exceptions, reraise -from sentry_sdk.consts import SPANDATA -from sentry_sdk.ai import set_data_normalized - -from ..spans import invoke_agent_span, update_invoke_agent_span -from ..utils import _capture_exception, pop_agent, push_agent - -from typing import TYPE_CHECKING - -try: - from pydantic_ai.agent import Agent # type: ignore -except ImportError: - raise DidNotEnable("pydantic-ai not installed") - -if TYPE_CHECKING: - from typing import Any, Callable, Optional, Union - - from pydantic_ai.agent.abstract import Instructions - from pydantic_ai.tools import SystemPromptFunc - - from sentry_sdk._types import TextPart - - -def _get_current_system_instructions( - agent_creation_time_instructions: "list[Union[str, SystemPromptFunc]]", - system_instructions: "Optional[Instructions[Any]]", -) -> "list[str]": - instruction_texts: "list[str]" = [] - - instruction_texts += [ - instruction - for instruction in agent_creation_time_instructions - if isinstance(instruction, str) - ] - - if isinstance(system_instructions, str): - instruction_texts.append(system_instructions) - - elif isinstance(system_instructions, Sequence): - instruction_texts += [ - instruction - for instruction in system_instructions - if isinstance(instruction, str) - ] - - return instruction_texts - - -def _transform_system_instructions( - permanent_instructions: "list[str]", current_instructions: "list[str]" -) -> "list[TextPart]": - return [ - { - "type": "text", - "content": instruction, - } - for instruction in itertools.chain(permanent_instructions, current_instructions) - ] - - -class _StreamingContextManagerWrapper: - """Wrapper for streaming methods that return async context managers.""" - - def __init__( - self, - agent: "Any", - original_ctx_manager: "Any", - user_prompt: "Any", - model: "Any", - model_settings: "Any", - is_streaming: bool = True, - ) -> None: - self.agent = agent - self.original_ctx_manager = original_ctx_manager - self.user_prompt = user_prompt - self.model = model - self.model_settings = model_settings - self.is_streaming = is_streaming - self._isolation_scope: "Any" = None - self._span: "Optional[sentry_sdk.tracing.Span]" = None - self._result: "Any" = None - - async def __aenter__(self) -> "Any": - # Set up isolation scope and invoke_agent span - self._isolation_scope = sentry_sdk.isolation_scope() - self._isolation_scope.__enter__() - - # Create invoke_agent span (will be closed in __aexit__) - self._span = invoke_agent_span( - self.user_prompt, - self.agent, - self.model, - self.model_settings, - self.is_streaming, - ) - self._span.__enter__() - - # Push agent to contextvar stack after span is successfully created and entered - # This ensures proper pairing with pop_agent() in __aexit__ even if exceptions occur - push_agent(self.agent, self.is_streaming) - - # Enter the original context manager - result = await self.original_ctx_manager.__aenter__() - self._result = result - return result - - async def __aexit__(self, exc_type: "Any", exc_val: "Any", exc_tb: "Any") -> None: - try: - # Exit the original context manager first - await self.original_ctx_manager.__aexit__(exc_type, exc_val, exc_tb) - - # Update span with result if successful - if exc_type is None and self._result and self._span is not None: - update_invoke_agent_span(self._span, self._result) - finally: - # Pop agent from contextvar stack - pop_agent() - - # Clean up invoke span - if self._span: - self._span.__exit__(exc_type, exc_val, exc_tb) - - # Clean up isolation scope - if self._isolation_scope: - self._isolation_scope.__exit__(exc_type, exc_val, exc_tb) - - -def _create_run_wrapper( - original_func: "Callable[..., Any]", is_streaming: bool = False -) -> "Callable[..., Any]": - """ - Wraps the Agent.run method to create an invoke_agent span. - - Args: - original_func: The original run method - is_streaming: Whether this is a streaming method (for future use) - """ - - @wraps(original_func) - async def wrapper(self: "Agent", *args: "Any", **kwargs: "Any") -> "Any": - # Isolate each workflow so that when agents are run in asyncio tasks they - # don't touch each other's scopes - with sentry_sdk.isolation_scope(): - # Extract parameters for the span - user_prompt = kwargs.get("user_prompt") or (args[0] if args else None) - model = kwargs.get("model") - model_settings = kwargs.get("model_settings") - - permanent_instructions = list(self._system_prompts) - - instructions: "Optional[Instructions[Any]]" = kwargs.get("instructions") - current_instructions = _get_current_system_instructions( - self._instructions, instructions - ) - - # Create invoke_agent span - with invoke_agent_span( - user_prompt, self, model, model_settings, is_streaming - ) as span: - if _should_send_prompts() and ( - len(permanent_instructions) > 0 or len(current_instructions) > 0 - ): - set_data_normalized( - span, - SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, - _transform_system_instructions( - permanent_instructions, current_instructions - ), - unpack=False, - ) - - # Push agent to contextvar stack after span is successfully created and entered - # This ensures proper pairing with pop_agent() in finally even if exceptions occur - push_agent(self, is_streaming) - - try: - result = await original_func(self, *args, **kwargs) - - # Update span with result - update_invoke_agent_span(span, result) - - return result - except Exception as exc: - exc_info = sys.exc_info() - with capture_internal_exceptions(): - _capture_exception(exc) - reraise(*exc_info) - finally: - # Pop agent from contextvar stack - pop_agent() - - return wrapper - - -def _create_streaming_wrapper( - original_func: "Callable[..., Any]", -) -> "Callable[..., Any]": - """ - Wraps run_stream method that returns an async context manager. - """ - - @wraps(original_func) - def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": - # Extract parameters for the span - user_prompt = kwargs.get("user_prompt") or (args[0] if args else None) - model = kwargs.get("model") - model_settings = kwargs.get("model_settings") - - # Call original function to get the context manager - original_ctx_manager = original_func(self, *args, **kwargs) - - # Wrap it with our instrumentation - return _StreamingContextManagerWrapper( - agent=self, - original_ctx_manager=original_ctx_manager, - user_prompt=user_prompt, - model=model, - model_settings=model_settings, - is_streaming=True, - ) - - return wrapper - - -def _create_streaming_events_wrapper( - original_func: "Callable[..., Any]", -) -> "Callable[..., Any]": - """ - Wraps run_stream_events method - no span needed as it delegates to run(). - - Note: run_stream_events internally calls self.run() with an event_stream_handler, - so the invoke_agent span will be created by the run() wrapper. - """ - - @wraps(original_func) - async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": - # Just call the original generator - it will call run() which has the instrumentation - try: - async for event in original_func(self, *args, **kwargs): - yield event - except Exception as exc: - exc_info = sys.exc_info() - with capture_internal_exceptions(): - _capture_exception(exc) - reraise(*exc_info) - - return wrapper - - -def _patch_agent_run() -> None: - """ - Patches the Agent run methods to create spans for agent execution. - - This patches both non-streaming (run, run_sync) and streaming - (run_stream, run_stream_events) methods. - """ - - # Store original methods - original_run = Agent.run - original_run_stream = Agent.run_stream - original_run_stream_events = Agent.run_stream_events - - # Wrap and apply patches for non-streaming methods - Agent.run = _create_run_wrapper(original_run, is_streaming=False) - - # Wrap and apply patches for streaming methods - Agent.run_stream = _create_streaming_wrapper(original_run_stream) - Agent.run_stream_events = _create_streaming_events_wrapper( - original_run_stream_events - ) From 2bafd3c762b0b5c66c91d9e0583bd114dfe6b996 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:33:34 +0200 Subject: [PATCH 05/33] . --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 0280893828..d99a0e9810 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -1221,9 +1221,12 @@ async def test_invoke_agent_with_instructions( agent = Agent( "test", name="test_instructions", - system_prompt="System prompt", ) + # Add instructions via _instructions attribute (internal API) + agent._instructions = ["Instruction 1", "Instruction 2"] + agent._system_prompts = ["System prompt"] + sentry_init( integrations=[PydanticAIIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, @@ -1234,7 +1237,6 @@ async def test_invoke_agent_with_instructions( await agent.run( "Test input", - instructions=["Instruction 1", "Instruction 2"], ) (transaction,) = events From 2d03ece8ee0375703008b24867b7049142cb0eef Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:34:05 +0200 Subject: [PATCH 06/33] . --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index d99a0e9810..10e97fbdd8 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -1235,9 +1235,7 @@ async def test_invoke_agent_with_instructions( events = capture_events() - await agent.run( - "Test input", - ) + await agent.run("Test input") (transaction,) = events spans = transaction["spans"] From 371c1bc9976eda97a8d79dfaeddca11c661c5329 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:41:24 +0200 Subject: [PATCH 07/33] add a streaming test --- .../pydantic_ai/test_pydantic_ai.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 10e97fbdd8..9a44424222 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -1256,6 +1256,65 @@ async def test_invoke_agent_with_instructions( assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +async def test_invoke_agent_streaming_with_instructions( + sentry_init, capture_events, send_default_pii, include_prompts +): + """ + Test that invoke_agent span handles instructions correctly. + """ + from pydantic_ai import Agent + + # Create agent with instructions (can be string or list) + agent = Agent( + "test", + name="test_instructions", + ) + + # Add instructions via _instructions attribute (internal API) + agent._instructions = ["Instruction 1", "Instruction 2"] + agent._system_prompts = ["System prompt"] + + sentry_init( + integrations=[PydanticAIIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + + events = capture_events() + + async with agent.run_stream("Test input") as result: + async for _ in result.stream_output(): + pass + + (transaction,) = events + spans = transaction["spans"] + + # The transaction IS the invoke_agent span, check for messages in chat spans instead + chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"] + assert len(chat_spans) >= 1 + + chat_span = chat_spans[0] + + if send_default_pii and include_prompts: + system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert json.loads(system_instructions) == [ + {"type": "text", "content": "System prompt"}, + {"type": "text", "content": "Instruction 1\nInstruction 2"}, + ] + else: + assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] + + @pytest.mark.asyncio async def test_model_name_extraction_with_callable(sentry_init, capture_events): """ From b77100997684c8ef6dd976e46f30fe72f98510e1 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 11:46:28 +0200 Subject: [PATCH 08/33] add pydantic-ai to linting requirements --- requirements-linting.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-linting.txt b/requirements-linting.txt index 7e5c143133..ad1d12fc12 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -10,6 +10,7 @@ types-webob opentelemetry-distro[otlp] pymongo # There is no separate types module. loguru # There is no separate types module. +pydantic-ai # There is no separate types module. pre-commit # local linting httpcore launchdarkly-server-sdk From 13da3962cb538fa1689ba6eac2261bd2cc549d3e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 12:01:16 +0200 Subject: [PATCH 09/33] re-add type ignore --- requirements-linting.txt | 1 - sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements-linting.txt b/requirements-linting.txt index ad1d12fc12..7e5c143133 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -10,7 +10,6 @@ types-webob opentelemetry-distro[otlp] pymongo # There is no separate types module. loguru # There is no separate types module. -pydantic-ai # There is no separate types module. pre-commit # local linting httpcore launchdarkly-server-sdk diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 2050ee6198..c4a102a67f 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -28,7 +28,7 @@ from pydantic_ai.messages import ( BaseToolCallPart, TextPart, - ) + ) # type: ignore except ImportError: # Fallback if these classes are not available BaseToolCallPart = None From 49dc6f49413cd8ba3f7d3ac0aac7c2bd502d85ff Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 12:03:20 +0200 Subject: [PATCH 10/33] move type ignore --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index c4a102a67f..a6a73fc876 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -25,10 +25,10 @@ from typing import Any try: - from pydantic_ai.messages import ( + from pydantic_ai.messages import ( # type: ignore BaseToolCallPart, TextPart, - ) # type: ignore + ) except ImportError: # Fallback if these classes are not available BaseToolCallPart = None From 1f1cc448f4bde5a2cecc4827a6f28aaf07c84ffe Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 12:57:18 +0200 Subject: [PATCH 11/33] add docstring --- sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index 70b1a68b4b..332d022f03 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -63,6 +63,9 @@ async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any": with ai_client_span(None, model, model_settings) as span: result = await original_model_request_run(self, ctx) + # The instructions are added in `_prepare_request` that runs as part of `ModelRequestNode.run`, so the input + # must be recorded after the call. See _get_instructions() added with + # https://github.com/pydantic/pydantic-ai/commit/f5271434a56c7a3bb5a3c93f2d1236d8b18afe3e if messages: _set_input_messages(span, messages) From cb41f97f5df297ea1f1ff8e7e930eb3bf906284b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 13:15:25 +0200 Subject: [PATCH 12/33] delay messages in stream path --- .../integrations/pydantic_ai/patches/graph_nodes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index 332d022f03..0bf10e259c 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -94,11 +94,15 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any": # Create chat span for streaming request with ai_client_span(None, model, model_settings) as span: - if messages: - _set_input_messages(span, messages) - # Call the original stream method async with original_stream_method(self, ctx) as stream: + # The instructions are added in `_prepare_request` that runs as part of __aenter__ on the + # context manager returned by `ModelRequestNode.stream()`, so the input must be recorded after the + # call. See _get_instructions() added with + # https://github.com/pydantic/pydantic-ai/commit/f5271434a56c7a3bb5a3c93f2d1236d8b18afe3e + if messages: + _set_input_messages(span, messages) + yield stream # After streaming completes, update span with response data From 3a043239293f0123dea462646be4e14da7a00e91 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 7 Apr 2026 14:41:02 +0200 Subject: [PATCH 13/33] use hooks --- .../integrations/pydantic_ai/__init__.py | 67 +++++- .../pydantic_ai/patches/agent_run.py | 8 + .../pydantic_ai/patches/graph_nodes.py | 18 +- .../pydantic_ai/patches/model_request.py | 7 +- .../pydantic_ai/spans/ai_client.py | 169 +++++++++++++- .../pydantic_ai/spans/invoke_agent.py | 4 +- .../integrations/pydantic_ai/spans/utils.py | 37 +++- sentry_sdk/integrations/pydantic_ai/utils.py | 208 +----------------- .../pydantic_ai/test_pydantic_ai.py | 141 ++++++++---- 9 files changed, 373 insertions(+), 286 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 0f0de53fa5..59aeff302a 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -1,8 +1,10 @@ -from sentry_sdk.integrations import DidNotEnable, Integration +import functools +from sentry_sdk.integrations import DidNotEnable, Integration try: import pydantic_ai # type: ignore # noqa: F401 + from pydantic_ai import Agent except ImportError: raise DidNotEnable("pydantic-ai not installed") @@ -14,6 +16,14 @@ _patch_tool_execution, ) +from .spans.ai_client import ai_client_span, update_ai_client_span + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pydantic_ai import ModelRequestContext, RunContext + from pydantic_ai.messages import ModelResponse + class PydanticAIIntegration(Integration): identifier = "pydantic_ai" @@ -45,6 +55,57 @@ def setup_once() -> None: - Tool executions """ _patch_agent_run() - _patch_graph_nodes() - _patch_model_request() + + try: + from pydantic_ai.capabilities import Hooks + + hooks = Hooks() + + @hooks.on.before_model_request + async def on_request( + ctx: "RunContext[None]", request_context: "ModelRequestContext" + ) -> "ModelRequestContext": + span = ai_client_span( + messages=request_context.messages, + agent=None, + model=request_context.model, + model_settings=request_context.model_settings, + ) + ctx.metadata["_sentry_span"] = span + span.__enter__() + + return request_context + + @hooks.on.after_model_request + async def on_response( + ctx: "RunContext[None]", + *, + request_context: "ModelRequestContext", + response: "ModelResponse", + ) -> "ModelResponse": + span = ctx.metadata["_sentry_span"] + if span is None: + return response + + update_ai_client_span(span, response) + span.__exit__(None, None, None) + del ctx.metadata["_sentry_span"] + + return response + + original_init = Agent.__init__ + + @functools.wraps(original_init) + def patched_init(self, *args, **kwargs): + caps = list(kwargs.get("capabilities") or []) + caps.append(hooks) + kwargs["capabilities"] = caps + original_init(self, *args, **kwargs) + + Agent.__init__ = patched_init + + except ImportError: + _patch_graph_nodes() + _patch_model_request() + _patch_tool_execution() diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index eaa4385834..7b963c93d6 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -107,6 +107,10 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {"_sentry_span": None} + # Create invoke_agent span with invoke_agent_span( user_prompt, self, model, model_settings, is_streaming @@ -148,6 +152,10 @@ def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {"_sentry_span": None} + # Call original function to get the context manager original_ctx_manager = original_func(self, *args, **kwargs) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py index 0bf10e259c..afb10395f4 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py @@ -7,7 +7,6 @@ ai_client_span, update_ai_client_span, ) -from ..utils import _set_input_messages try: from pydantic_ai._agent_graph import ModelRequestNode # type: ignore @@ -60,15 +59,9 @@ def _patch_graph_nodes() -> None: async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any": messages, model, model_settings = _extract_span_data(self, ctx) - with ai_client_span(None, model, model_settings) as span: + with ai_client_span(messages, None, model, model_settings) as span: result = await original_model_request_run(self, ctx) - # The instructions are added in `_prepare_request` that runs as part of `ModelRequestNode.run`, so the input - # must be recorded after the call. See _get_instructions() added with - # https://github.com/pydantic/pydantic-ai/commit/f5271434a56c7a3bb5a3c93f2d1236d8b18afe3e - if messages: - _set_input_messages(span, messages) - # Extract response from result if available model_response = None if hasattr(result, "model_response"): @@ -93,16 +86,9 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any": messages, model, model_settings = _extract_span_data(self, ctx) # Create chat span for streaming request - with ai_client_span(None, model, model_settings) as span: + with ai_client_span(messages, None, model, model_settings) as span: # Call the original stream method async with original_stream_method(self, ctx) as stream: - # The instructions are added in `_prepare_request` that runs as part of __aenter__ on the - # context manager returned by `ModelRequestNode.stream()`, so the input must be recorded after the - # call. See _get_instructions() added with - # https://github.com/pydantic/pydantic-ai/commit/f5271434a56c7a3bb5a3c93f2d1236d8b18afe3e - if messages: - _set_input_messages(span, messages) - yield stream # After streaming completes, update span with response data diff --git a/sentry_sdk/integrations/pydantic_ai/patches/model_request.py b/sentry_sdk/integrations/pydantic_ai/patches/model_request.py index e7823c50c6..94a96161f3 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/model_request.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/model_request.py @@ -3,8 +3,6 @@ from sentry_sdk.integrations import DidNotEnable -from ..utils import _set_input_messages - try: from pydantic_ai import models # type: ignore except ImportError: @@ -34,10 +32,7 @@ async def wrapped_request( self: "Any", messages: "Any", *args: "Any", **kwargs: "Any" ) -> "Any": # Pass all messages (full conversation history) - with ai_client_span(None, self, None) as span: - if messages: - _set_input_messages(span, messages) - + with ai_client_span(messages, None, self, None) as span: result = await original_request(self, messages, *args, **kwargs) update_ai_client_span(span, result) return result diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index a6a73fc876..dc95acad45 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -1,6 +1,10 @@ +import json + import sentry_sdk from sentry_sdk.ai.utils import ( + normalize_message_roles, set_data_normalized, + truncate_and_annotate_messages, ) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.utils import safe_serialize @@ -16,23 +20,178 @@ get_is_streaming, ) from .utils import ( + _serialize_binary_content_item, + _serialize_image_url_item, _set_usage_data, ) from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, List, Dict + from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore + from sentry_sdk._types import TextPart as SentryTextPart try: - from pydantic_ai.messages import ( # type: ignore + from pydantic_ai.messages import ( BaseToolCallPart, + BaseToolReturnPart, + SystemPromptPart, + UserPromptPart, TextPart, + ThinkingPart, + BinaryContent, + ImageUrl, ) except ImportError: # Fallback if these classes are not available BaseToolCallPart = None + BaseToolReturnPart = None + SystemPromptPart = None + UserPromptPart = None TextPart = None + ThinkingPart = None + BinaryContent = None + ImageUrl = None + + +def _transform_system_instructions( + permanent_instructions: "list[SystemPromptPart]", + current_instructions: "list[str]", +) -> "list[SentryTextPart]": + text_parts: "list[SentryTextPart]" = [ + { + "type": "text", + "content": instruction.content, + } + for instruction in permanent_instructions + ] + + text_parts.extend( + { + "type": "text", + "content": instruction, + } + for instruction in current_instructions + ) + + return text_parts + + +def _get_system_instructions( + messages: "list[ModelMessage]", +) -> "tuple[list[SystemPromptPart], list[str]]": + permanent_instructions = [] + current_instructions = [] + + for msg in messages: + if hasattr(msg, "parts"): + for part in msg.parts: + if SystemPromptPart and isinstance(part, SystemPromptPart): + permanent_instructions.append(part) + + if hasattr(msg, "instructions") and msg.instructions is not None: + current_instructions.append(msg.instructions) + + return permanent_instructions, current_instructions + + +def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: + """Set input messages data on a span.""" + if not _should_send_prompts(): + return + + if not messages: + return + + permanent_instructions, current_instructions = _get_system_instructions(messages) + if len(permanent_instructions) > 0 or len(current_instructions) > 0: + span.set_data( + SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, + json.dumps( + _transform_system_instructions( + permanent_instructions, current_instructions + ) + ), + ) + + try: + formatted_messages = [] + + for msg in messages: + if hasattr(msg, "parts"): + for part in msg.parts: + role = "user" + # Use isinstance checks with proper base classes + if SystemPromptPart and isinstance(part, SystemPromptPart): + continue + elif ( + (TextPart and isinstance(part, TextPart)) + or (ThinkingPart and isinstance(part, ThinkingPart)) + or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) + ): + role = "assistant" + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + role = "tool" + + content: "List[Dict[str, Any] | str]" = [] + tool_calls = None + tool_call_id = None + + # Handle ToolCallPart (assistant requesting tool use) + if BaseToolCallPart and isinstance(part, BaseToolCallPart): + tool_call_data = {} + if hasattr(part, "tool_name"): + tool_call_data["name"] = part.tool_name + if hasattr(part, "args"): + tool_call_data["arguments"] = safe_serialize(part.args) + if tool_call_data: + tool_calls = [tool_call_data] + # Handle ToolReturnPart (tool result) + elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): + 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 + elif hasattr(part, "content"): + if isinstance(part.content, str): + 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", "text": item}) + elif ImageUrl and isinstance(item, ImageUrl): + content.append(_serialize_image_url_item(item)) + elif BinaryContent and isinstance(item, BinaryContent): + 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} + if content: + message["content"] = content + if tool_calls: + message["tool_calls"] = tool_calls + if tool_call_id: + message["tool_call_id"] = tool_call_id + formatted_messages.append(message) + + if formatted_messages: + normalized_messages = normalize_message_roles(formatted_messages) + scope = sentry_sdk.get_current_scope() + messages_data = truncate_and_annotate_messages( + normalized_messages, span, scope + ) + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False + ) + except Exception: + # If we fail to format messages, just skip it + pass def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None: @@ -77,7 +236,7 @@ def _set_output_data(span: "sentry_sdk.tracing.Span", response: "Any") -> None: def ai_client_span( - agent: "Any", model: "Any", model_settings: "Any" + messages: "Any", agent: "Any", model: "Any", model_settings: "Any" ) -> "sentry_sdk.tracing.Span": """Create a span for an AI client call (model request). @@ -112,6 +271,10 @@ def ai_client_span( agent_obj = agent or get_current_agent() _set_available_tools(span, agent_obj) + # Set input messages (full conversation history) + if messages: + _set_input_messages(span, messages) + return span diff --git a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py index 7db60b5c7f..ee08ca7036 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py @@ -13,10 +13,10 @@ _set_available_tools, _set_model_data, _should_send_prompts, - _serialize_binary_content_item, - _serialize_image_url_item, ) from .utils import ( + _serialize_binary_content_item, + _serialize_image_url_item, _set_usage_data, ) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/utils.py b/sentry_sdk/integrations/pydantic_ai/spans/utils.py index e5538eba3c..70e47dc034 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/utils.py @@ -1,15 +1,50 @@ """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 + from typing import Union, Dict, Any from pydantic_ai.usage import RequestUsage, RunUsage # type: ignore +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. + """ + url = str(item.url) + data_url_match = DATA_URL_BASE64_REGEX.match(url) + + if data_url_match: + return { + "type": "image", + "content": BLOB_DATA_SUBSTITUTE, + } + + return { + "type": "image", + "content": 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]" ) -> None: diff --git a/sentry_sdk/integrations/pydantic_ai/utils.py b/sentry_sdk/integrations/pydantic_ai/utils.py index 424acab110..62d36fb912 100644 --- a/sentry_sdk/integrations/pydantic_ai/utils.py +++ b/sentry_sdk/integrations/pydantic_ai/utils.py @@ -1,5 +1,3 @@ -import json - import sentry_sdk from contextvars import ContextVar from sentry_sdk.consts import SPANDATA @@ -7,44 +5,10 @@ from sentry_sdk.tracing_utils import set_span_errored from sentry_sdk.utils import event_from_exception, safe_serialize -from sentry_sdk.ai.utils import ( - normalize_message_roles, - set_data_normalized, - truncate_and_annotate_messages, -) -from sentry_sdk._types import BLOB_DATA_SUBSTITUTE -from sentry_sdk.ai.utils import get_modality_from_mime_type - - -from .consts import DATA_URL_BASE64_REGEX - from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Optional, List, Dict - from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore - from sentry_sdk._types import TextPart as SentryTextPart - - -try: - from pydantic_ai.messages import ( - BaseToolCallPart, - BaseToolReturnPart, - SystemPromptPart, - TextPart, - ThinkingPart, - BinaryContent, - ImageUrl, - ) -except ImportError: - # Fallback if these classes are not available - BaseToolCallPart = None - BaseToolReturnPart = None - SystemPromptPart = None - TextPart = None - ThinkingPart = None - BinaryContent = None - ImageUrl = None + from typing import Any, Optional # Store the current agent context in a contextvar for re-entrant safety @@ -251,173 +215,3 @@ def _capture_exception(exc: "Any", handled: bool = False) -> None: mechanism={"type": "pydantic_ai", "handled": handled}, ) sentry_sdk.capture_event(event, hint=hint) - - -def _transform_system_instructions( - permanent_instructions: "list[SystemPromptPart]", - current_instructions: "list[str]", -) -> "list[SentryTextPart]": - text_parts: "list[SentryTextPart]" = [ - { - "type": "text", - "content": instruction.content, - } - for instruction in permanent_instructions - ] - - text_parts.extend( - { - "type": "text", - "content": instruction, - } - for instruction in current_instructions - ) - - return text_parts - - -def _get_system_instructions( - messages: "list[ModelMessage]", -) -> "tuple[list[SystemPromptPart], list[str]]": - permanent_instructions = [] - current_instructions = [] - - for msg in messages: - if hasattr(msg, "parts"): - for part in msg.parts: - if SystemPromptPart and isinstance(part, SystemPromptPart): - permanent_instructions.append(part) - - if hasattr(msg, "instructions") and msg.instructions is not None: - current_instructions.append(msg.instructions) - - return permanent_instructions, current_instructions - - -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. - """ - url = str(item.url) - data_url_match = DATA_URL_BASE64_REGEX.match(url) - - if data_url_match: - return { - "type": "image", - "content": BLOB_DATA_SUBSTITUTE, - } - - return { - "type": "image", - "content": 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_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: - """Set input messages data on a span.""" - if not _should_send_prompts(): - return - - if not messages: - return - - permanent_instructions, current_instructions = _get_system_instructions(messages) - if len(permanent_instructions) > 0 or len(current_instructions) > 0: - span.set_data( - SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, - json.dumps( - _transform_system_instructions( - permanent_instructions, current_instructions - ) - ), - ) - - try: - formatted_messages = [] - - for msg in messages: - if hasattr(msg, "parts"): - for part in msg.parts: - role = "user" - # Use isinstance checks with proper base classes - if SystemPromptPart and isinstance(part, SystemPromptPart): - continue - elif ( - (TextPart and isinstance(part, TextPart)) - or (ThinkingPart and isinstance(part, ThinkingPart)) - or (BaseToolCallPart and isinstance(part, BaseToolCallPart)) - ): - role = "assistant" - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): - role = "tool" - - content: "List[Dict[str, Any] | str]" = [] - tool_calls = None - tool_call_id = None - - # Handle ToolCallPart (assistant requesting tool use) - if BaseToolCallPart and isinstance(part, BaseToolCallPart): - tool_call_data = {} - if hasattr(part, "tool_name"): - tool_call_data["name"] = part.tool_name - if hasattr(part, "args"): - tool_call_data["arguments"] = safe_serialize(part.args) - if tool_call_data: - tool_calls = [tool_call_data] - # Handle ToolReturnPart (tool result) - elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart): - 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 - elif hasattr(part, "content"): - if isinstance(part.content, str): - 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", "text": item}) - elif ImageUrl and isinstance(item, ImageUrl): - content.append(_serialize_image_url_item(item)) - elif BinaryContent and isinstance(item, BinaryContent): - 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} - if content: - message["content"] = content - if tool_calls: - message["tool_calls"] = tool_calls - if tool_call_id: - message["tool_call_id"] = tool_call_id - formatted_messages.append(message) - - if formatted_messages: - normalized_messages = normalize_message_roles(formatted_messages) - scope = sentry_sdk.get_current_scope() - messages_data = truncate_and_annotate_messages( - normalized_messages, span, scope - ) - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False - ) - except Exception: - # If we fail to format messages, just skip it - pass diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 9a44424222..d71e235295 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -10,7 +10,7 @@ from sentry_sdk._types import BLOB_DATA_SUBSTITUTE from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration -from sentry_sdk.integrations.pydantic_ai.utils import _set_input_messages +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 pydantic_ai import Agent from pydantic_ai.messages import BinaryContent, ImageUrl, UserPromptPart @@ -19,34 +19,40 @@ @pytest.fixture -def test_agent(): - """Create a test agent with model settings.""" - return Agent( - "test", - name="test_agent", - system_prompt="You are a helpful test assistant.", - ) +def get_test_agent(): + def inner(): + """Create a test agent with model settings.""" + return Agent( + "test", + name="test_agent", + system_prompt="You are a helpful test assistant.", + ) + + return inner @pytest.fixture -def test_agent_with_settings(): - """Create a test agent with explicit model settings.""" - from pydantic_ai import ModelSettings +def get_test_agent_with_settings(): + def inner(): + """Create a test agent with explicit model settings.""" + from pydantic_ai import ModelSettings + + return Agent( + "test", + name="test_agent_settings", + system_prompt="You are a test assistant with settings.", + model_settings=ModelSettings( + temperature=0.7, + max_tokens=100, + top_p=0.9, + ), + ) - return Agent( - "test", - name="test_agent_settings", - system_prompt="You are a test assistant with settings.", - model_settings=ModelSettings( - temperature=0.7, - max_tokens=100, - top_p=0.9, - ), - ) + return inner @pytest.mark.asyncio -async def test_agent_run_async(sentry_init, capture_events, test_agent): +async def test_agent_run_async(sentry_init, capture_events, get_test_agent): """ Test that the integration creates spans for async agent runs. """ @@ -58,6 +64,7 @@ async def test_agent_run_async(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() result = await test_agent.run("Test input") assert result is not None @@ -88,7 +95,7 @@ async def test_agent_run_async(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_agent_run_async_usage_data(sentry_init, capture_events, test_agent): +async def test_agent_run_async_usage_data(sentry_init, capture_events, get_test_agent): """ Test that the invoke_agent span includes token usage and model data. """ @@ -100,6 +107,7 @@ async def test_agent_run_async_usage_data(sentry_init, capture_events, test_agen events = capture_events() + test_agent = get_test_agent() result = await test_agent.run("Test input") assert result is not None @@ -132,7 +140,7 @@ async def test_agent_run_async_usage_data(sentry_init, capture_events, test_agen assert trace_data["gen_ai.response.model"] == "test" # Test model name -def test_agent_run_sync(sentry_init, capture_events, test_agent): +def test_agent_run_sync(sentry_init, capture_events, get_test_agent): """ Test that the integration creates spans for sync agent runs. """ @@ -144,6 +152,7 @@ def test_agent_run_sync(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() result = test_agent.run_sync("Test input") assert result is not None @@ -166,7 +175,7 @@ def test_agent_run_sync(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_agent_run_stream(sentry_init, capture_events, test_agent): +async def test_agent_run_stream(sentry_init, capture_events, get_test_agent): """ Test that the integration creates spans for streaming agent runs. """ @@ -178,6 +187,7 @@ async def test_agent_run_stream(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() async with test_agent.run_stream("Test input") as result: # Consume the stream async for _ in result.stream_output(): @@ -207,7 +217,7 @@ async def test_agent_run_stream(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_agent_run_stream_events(sentry_init, capture_events, test_agent): +async def test_agent_run_stream_events(sentry_init, capture_events, get_test_agent): """ Test that run_stream_events creates spans (it uses run internally, so non-streaming). """ @@ -220,6 +230,7 @@ async def test_agent_run_stream_events(sentry_init, capture_events, test_agent): events = capture_events() # Consume all events + test_agent = get_test_agent() async for _ in test_agent.run_stream_events("Test input"): pass @@ -239,11 +250,13 @@ async def test_agent_run_stream_events(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_agent_with_tools(sentry_init, capture_events, test_agent): +async def test_agent_with_tools(sentry_init, capture_events, get_test_agent): """ Test that tool execution creates execute_tool spans. """ + test_agent = get_test_agent() + @test_agent.tool_plain def add_numbers(a: int, b: int) -> int: """Add two numbers together.""" @@ -294,7 +307,7 @@ def add_numbers(a: int, b: int) -> int: ) @pytest.mark.asyncio async def test_agent_with_tool_model_retry( - sentry_init, capture_events, test_agent, handled_tool_call_exceptions + sentry_init, capture_events, get_test_agent, handled_tool_call_exceptions ): """ Test that a handled exception is captured when a tool raises ModelRetry. @@ -302,6 +315,8 @@ async def test_agent_with_tool_model_retry( retries = 0 + test_agent = get_test_agent() + @test_agent.tool_plain def add_numbers(a: int, b: int) -> float: """Add two numbers together, but raises an exception on the first attempt.""" @@ -374,12 +389,14 @@ def add_numbers(a: int, b: int) -> float: ) @pytest.mark.asyncio async def test_agent_with_tool_validation_error( - sentry_init, capture_events, test_agent, handled_tool_call_exceptions + sentry_init, capture_events, get_test_agent, handled_tool_call_exceptions ): """ Test that a handled exception is captured when a tool has unsatisfiable constraints. """ + test_agent = get_test_agent() + @test_agent.tool_plain def add_numbers(a: Annotated[int, Field(gt=0, lt=0)], b: int) -> int: """Add two numbers together.""" @@ -440,11 +457,13 @@ def add_numbers(a: Annotated[int, Field(gt=0, lt=0)], b: int) -> int: @pytest.mark.asyncio -async def test_agent_with_tools_streaming(sentry_init, capture_events, test_agent): +async def test_agent_with_tools_streaming(sentry_init, capture_events, get_test_agent): """ Test that tool execution works correctly with streaming. """ + test_agent = get_test_agent() + @test_agent.tool_plain def multiply(a: int, b: int) -> int: """Multiply two numbers.""" @@ -484,7 +503,9 @@ def multiply(a: int, b: int) -> int: @pytest.mark.asyncio -async def test_model_settings(sentry_init, capture_events, test_agent_with_settings): +async def test_model_settings( + sentry_init, capture_events, get_test_agent_with_settings +): """ Test that model settings are captured in spans. """ @@ -495,6 +516,7 @@ async def test_model_settings(sentry_init, capture_events, test_agent_with_setti events = capture_events() + test_agent_with_settings = get_test_agent_with_settings() await test_agent_with_settings.run("Test input") (transaction,) = events @@ -596,7 +618,7 @@ async def test_error_handling(sentry_init, capture_events): @pytest.mark.asyncio -async def test_without_pii(sentry_init, capture_events, test_agent): +async def test_without_pii(sentry_init, capture_events, get_test_agent): """ Test that PII is not captured when send_default_pii is False. """ @@ -608,6 +630,7 @@ async def test_without_pii(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() await test_agent.run("Sensitive input") (transaction,) = events @@ -623,11 +646,13 @@ async def test_without_pii(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_without_pii_tools(sentry_init, capture_events, test_agent): +async def test_without_pii_tools(sentry_init, capture_events, get_test_agent): """ Test that tool input/output are not captured when send_default_pii is False. """ + test_agent = get_test_agent() + @test_agent.tool_plain def sensitive_tool(data: str) -> str: """A tool with sensitive data.""" @@ -656,7 +681,7 @@ def sensitive_tool(data: str) -> str: @pytest.mark.asyncio -async def test_multiple_agents_concurrent(sentry_init, capture_events, test_agent): +async def test_multiple_agents_concurrent(sentry_init, capture_events, get_test_agent): """ Test that multiple agents can run concurrently without interfering. """ @@ -667,6 +692,8 @@ async def test_multiple_agents_concurrent(sentry_init, capture_events, test_agen events = capture_events() + test_agent = get_test_agent() + async def run_agent(input_text): return await test_agent.run(input_text) @@ -737,7 +764,7 @@ async def test_message_history(sentry_init, capture_events): @pytest.mark.asyncio -async def test_gen_ai_system(sentry_init, capture_events, test_agent): +async def test_gen_ai_system(sentry_init, capture_events, get_test_agent): """ Test that gen_ai.system is set from the model. """ @@ -748,6 +775,7 @@ async def test_gen_ai_system(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() await test_agent.run("Test input") (transaction,) = events @@ -764,7 +792,7 @@ async def test_gen_ai_system(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_include_prompts_false(sentry_init, capture_events, test_agent): +async def test_include_prompts_false(sentry_init, capture_events, get_test_agent): """ Test that prompts are not captured when include_prompts=False. """ @@ -776,6 +804,7 @@ async def test_include_prompts_false(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() await test_agent.run("Sensitive prompt") (transaction,) = events @@ -791,7 +820,7 @@ async def test_include_prompts_false(sentry_init, capture_events, test_agent): @pytest.mark.asyncio -async def test_include_prompts_true(sentry_init, capture_events, test_agent): +async def test_include_prompts_true(sentry_init, capture_events, get_test_agent): """ Test that prompts are captured when include_prompts=True (default). """ @@ -803,6 +832,7 @@ async def test_include_prompts_true(sentry_init, capture_events, test_agent): events = capture_events() + test_agent = get_test_agent() await test_agent.run("Test prompt") (transaction,) = events @@ -819,12 +849,14 @@ async def test_include_prompts_true(sentry_init, capture_events, test_agent): @pytest.mark.asyncio async def test_include_prompts_false_with_tools( - sentry_init, capture_events, test_agent + sentry_init, capture_events, get_test_agent ): """ Test that tool input/output are not captured when include_prompts=False. """ + test_agent = get_test_agent() + @test_agent.tool_plain def test_tool(value: int) -> int: """A test tool.""" @@ -853,7 +885,9 @@ def test_tool(value: int) -> int: @pytest.mark.asyncio -async def test_include_prompts_requires_pii(sentry_init, capture_events, test_agent): +async def test_include_prompts_requires_pii( + sentry_init, capture_events, get_test_agent +): """ Test that include_prompts requires send_default_pii=True. """ @@ -865,6 +899,7 @@ async def test_include_prompts_requires_pii(sentry_init, capture_events, test_ag events = capture_events() + test_agent = get_test_agent() await test_agent.run("Test prompt") (transaction,) = events @@ -1015,7 +1050,7 @@ async def mock_map_tool_result_part(part): @pytest.mark.asyncio -async def test_context_cleanup_after_run(sentry_init, test_agent): +async def test_context_cleanup_after_run(sentry_init, get_test_agent): """ Test that the pydantic_ai_agent context is properly cleaned up after agent execution. """ @@ -1031,13 +1066,14 @@ async def test_context_cleanup_after_run(sentry_init, test_agent): assert "pydantic_ai_agent" not in scope._contexts # Run the agent + test_agent = get_test_agent() await test_agent.run("Test input") # Verify context is cleaned up after run assert "pydantic_ai_agent" not in scope._contexts -def test_context_cleanup_after_run_sync(sentry_init, test_agent): +def test_context_cleanup_after_run_sync(sentry_init, get_test_agent): """ Test that the pydantic_ai_agent context is properly cleaned up after sync agent execution. """ @@ -1053,6 +1089,7 @@ def test_context_cleanup_after_run_sync(sentry_init, test_agent): assert "pydantic_ai_agent" not in scope._contexts # Run the agent synchronously + test_agent = get_test_agent() test_agent.run_sync("Test input") # Verify context is cleaned up after run @@ -1060,7 +1097,7 @@ def test_context_cleanup_after_run_sync(sentry_init, test_agent): @pytest.mark.asyncio -async def test_context_cleanup_after_streaming(sentry_init, test_agent): +async def test_context_cleanup_after_streaming(sentry_init, get_test_agent): """ Test that the pydantic_ai_agent context is properly cleaned up after streaming execution. """ @@ -1075,6 +1112,7 @@ async def test_context_cleanup_after_streaming(sentry_init, test_agent): scope = sentry_sdk.get_current_scope() assert "pydantic_ai_agent" not in scope._contexts + test_agent = get_test_agent() # Run the agent with streaming async with test_agent.run_stream("Test input") as result: async for _ in result.stream_output(): @@ -1085,12 +1123,14 @@ async def test_context_cleanup_after_streaming(sentry_init, test_agent): @pytest.mark.asyncio -async def test_context_cleanup_on_error(sentry_init, test_agent): +async def test_context_cleanup_on_error(sentry_init, get_test_agent): """ Test that the pydantic_ai_agent context is cleaned up even when an error occurs. """ import sentry_sdk + test_agent = get_test_agent() + # Create an agent with a tool that raises an error @test_agent.tool_plain def failing_tool() -> str: @@ -1117,7 +1157,7 @@ def failing_tool() -> str: @pytest.mark.asyncio -async def test_context_isolation_concurrent_agents(sentry_init, test_agent): +async def test_context_isolation_concurrent_agents(sentry_init, get_test_agent): """ Test that concurrent agent executions maintain isolated contexts. """ @@ -1150,6 +1190,7 @@ async def run_and_check_context(agent, agent_name): return agent_name + test_agent = get_test_agent() # Run both agents concurrently results = await asyncio.gather( run_and_check_context(test_agent, "agent1"), @@ -1462,12 +1503,14 @@ async def test_agent_data_from_scope(sentry_init, capture_events): @pytest.mark.asyncio async def test_available_tools_without_description( - sentry_init, capture_events, test_agent + sentry_init, capture_events, get_test_agent ): """ Test that available tools are captured even when description is missing. """ + test_agent = get_test_agent() + @test_agent.tool_plain def tool_without_desc(x: int) -> int: # No docstring = no description @@ -1494,11 +1537,13 @@ def tool_without_desc(x: int) -> int: @pytest.mark.asyncio -async def test_output_with_tool_calls(sentry_init, capture_events, test_agent): +async def test_output_with_tool_calls(sentry_init, capture_events, get_test_agent): """ Test that tool calls in model response are captured correctly. """ + test_agent = get_test_agent() + @test_agent.tool_plain def calc_tool(value: int) -> int: """Calculate something.""" @@ -2666,7 +2711,7 @@ async def test_ai_client_span_with_streaming_flag(sentry_init, capture_events): scope._contexts["pydantic_ai_agent"] = {"_streaming": True} # Create ai_client span - span = ai_client_span(None, None, None) + span = ai_client_span([], None, None, None) span.finish() # Should not crash @@ -2697,7 +2742,7 @@ async def test_ai_client_span_gets_agent_from_scope(sentry_init, capture_events) scope._contexts["pydantic_ai_agent"] = {"_agent": mock_agent} # Create ai_client span without passing agent - span = ai_client_span(None, None, None) + span = ai_client_span([], None, None, None) span.finish() # Should not crash From 6bd8586560f12d19f5f198ee3a82d9d75f1c8992 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 09:32:40 +0200 Subject: [PATCH 14/33] type ignores --- sentry_sdk/integrations/pydantic_ai/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 59aeff302a..2fe6ab312a 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from pydantic_ai import ModelRequestContext, RunContext - from pydantic_ai.messages import ModelResponse + from pydantic_ai.messages import ModelResponse # type: ignore class PydanticAIIntegration(Integration): @@ -57,11 +57,11 @@ def setup_once() -> None: _patch_agent_run() try: - from pydantic_ai.capabilities import Hooks + from pydantic_ai.capabilities import Hooks # type: ignore hooks = Hooks() - @hooks.on.before_model_request + @hooks.on.before_model_request # type: ignore async def on_request( ctx: "RunContext[None]", request_context: "ModelRequestContext" ) -> "ModelRequestContext": @@ -76,7 +76,7 @@ async def on_request( return request_context - @hooks.on.after_model_request + @hooks.on.after_model_request # type: ignore async def on_response( ctx: "RunContext[None]", *, @@ -96,11 +96,11 @@ async def on_response( original_init = Agent.__init__ @functools.wraps(original_init) - def patched_init(self, *args, **kwargs): + def patched_init(self, *args, **kwargs) -> None: caps = list(kwargs.get("capabilities") or []) caps.append(hooks) kwargs["capabilities"] = caps - original_init(self, *args, **kwargs) + return original_init(self, *args, **kwargs) Agent.__init__ = patched_init From d65fdb121cde953509a2cb3b7b72743ac9f60def Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 09:34:11 +0200 Subject: [PATCH 15/33] remove test --- .../pydantic_ai/test_pydantic_ai.py | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index d71e235295..cc2f6d0b4c 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -1297,65 +1297,6 @@ async def test_invoke_agent_with_instructions( assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] -@pytest.mark.asyncio -@pytest.mark.parametrize( - "send_default_pii, include_prompts", - [ - (True, True), - (True, False), - (False, True), - (False, False), - ], -) -async def test_invoke_agent_streaming_with_instructions( - sentry_init, capture_events, send_default_pii, include_prompts -): - """ - Test that invoke_agent span handles instructions correctly. - """ - from pydantic_ai import Agent - - # Create agent with instructions (can be string or list) - agent = Agent( - "test", - name="test_instructions", - ) - - # Add instructions via _instructions attribute (internal API) - agent._instructions = ["Instruction 1", "Instruction 2"] - agent._system_prompts = ["System prompt"] - - sentry_init( - integrations=[PydanticAIIntegration(include_prompts=include_prompts)], - traces_sample_rate=1.0, - send_default_pii=send_default_pii, - ) - - events = capture_events() - - async with agent.run_stream("Test input") as result: - async for _ in result.stream_output(): - pass - - (transaction,) = events - spans = transaction["spans"] - - # The transaction IS the invoke_agent span, check for messages in chat spans instead - chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"] - assert len(chat_spans) >= 1 - - chat_span = chat_spans[0] - - if send_default_pii and include_prompts: - system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] - assert json.loads(system_instructions) == [ - {"type": "text", "content": "System prompt"}, - {"type": "text", "content": "Instruction 1\nInstruction 2"}, - ] - else: - assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] - - @pytest.mark.asyncio async def test_model_name_extraction_with_callable(sentry_init, capture_events): """ From 0c4e443c6110bda78d4491ba542951f84c403804 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 09:51:15 +0200 Subject: [PATCH 16/33] . --- .../integrations/pydantic_ai/__init__.py | 92 ++++++++++--------- .../pydantic_ai/patches/agent_run.py | 16 ++-- 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 2fe6ab312a..1388ffc6dd 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -28,6 +28,7 @@ class PydanticAIIntegration(Integration): identifier = "pydantic_ai" origin = f"auto.ai.{identifier}" + are_request_hooks_available = True def __init__( self, include_prompts: bool = True, handled_tool_call_exceptions: bool = True @@ -58,54 +59,59 @@ def setup_once() -> None: try: from pydantic_ai.capabilities import Hooks # type: ignore + except ImportError: + Hooks = None + # Save to populate ctx.metadata + PydanticAIIntegration.are_request_hooks_available = True - hooks = Hooks() - - @hooks.on.before_model_request # type: ignore - async def on_request( - ctx: "RunContext[None]", request_context: "ModelRequestContext" - ) -> "ModelRequestContext": - span = ai_client_span( - messages=request_context.messages, - agent=None, - model=request_context.model, - model_settings=request_context.model_settings, - ) - ctx.metadata["_sentry_span"] = span - span.__enter__() - - return request_context - - @hooks.on.after_model_request # type: ignore - async def on_response( - ctx: "RunContext[None]", - *, - request_context: "ModelRequestContext", - response: "ModelResponse", - ) -> "ModelResponse": - span = ctx.metadata["_sentry_span"] - if span is None: - return response - - update_ai_client_span(span, response) - span.__exit__(None, None, None) - del ctx.metadata["_sentry_span"] + if Hooks is None: + _patch_graph_nodes() + _patch_model_request() + return + + _patch_tool_execution() + hooks = Hooks() + + @hooks.on.before_model_request # type: ignore + async def on_request( + ctx: "RunContext[None]", request_context: "ModelRequestContext" + ) -> "ModelRequestContext": + span = ai_client_span( + messages=request_context.messages, + agent=None, + model=request_context.model, + model_settings=request_context.model_settings, + ) + ctx.metadata["_sentry_span"] = span + span.__enter__() + + return request_context + + @hooks.on.after_model_request # type: ignore + async def on_response( + ctx: "RunContext[None]", + *, + request_context: "ModelRequestContext", + response: "ModelResponse", + ) -> "ModelResponse": + span = ctx.metadata["_sentry_span"] + if span is None: return response - original_init = Agent.__init__ + update_ai_client_span(span, response) + span.__exit__(None, None, None) + del ctx.metadata["_sentry_span"] - @functools.wraps(original_init) - def patched_init(self, *args, **kwargs) -> None: - caps = list(kwargs.get("capabilities") or []) - caps.append(hooks) - kwargs["capabilities"] = caps - return original_init(self, *args, **kwargs) + return response - Agent.__init__ = patched_init + original_init = Agent.__init__ - except ImportError: - _patch_graph_nodes() - _patch_model_request() + @functools.wraps(original_init) + def patched_init(self, *args, **kwargs) -> None: + caps = list(kwargs.get("capabilities") or []) + caps.append(hooks) + kwargs["capabilities"] = caps + return original_init(self, *args, **kwargs) - _patch_tool_execution() + Agent.__init__ = patched_init diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index 7b963c93d6..a21732c26b 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -96,6 +96,7 @@ def _create_run_wrapper( original_func: The original run method is_streaming: Whether this is a streaming method (for future use) """ + from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration @wraps(original_func) async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -107,9 +108,10 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") - metadata = kwargs.get("metadata") - if not metadata: - kwargs["metadata"] = {"_sentry_span": None} + if PydanticAIIntegration.are_request_hooks_available: + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {"_sentry_span": None} # Create invoke_agent span with invoke_agent_span( @@ -144,6 +146,7 @@ def _create_streaming_wrapper( """ Wraps run_stream method that returns an async context manager. """ + from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration @wraps(original_func) def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -152,9 +155,10 @@ def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") - metadata = kwargs.get("metadata") - if not metadata: - kwargs["metadata"] = {"_sentry_span": None} + if PydanticAIIntegration.are_request_hooks_available: + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {"_sentry_span": None} # Call original function to get the context manager original_ctx_manager = original_func(self, *args, **kwargs) From ba53213ca34b361e01db848be2a78e07eee8fa6a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 09:55:34 +0200 Subject: [PATCH 17/33] add annotations --- sentry_sdk/integrations/pydantic_ai/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 1388ffc6dd..26d5a614f0 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from typing import Any from pydantic_ai import ModelRequestContext, RunContext from pydantic_ai.messages import ModelResponse # type: ignore @@ -108,7 +109,9 @@ async def on_response( original_init = Agent.__init__ @functools.wraps(original_init) - def patched_init(self, *args, **kwargs) -> None: + def patched_init( + self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any" + ) -> None: caps = list(kwargs.get("capabilities") or []) caps.append(hooks) kwargs["capabilities"] = caps From 1c4fb70c6dfa6e325282274c4264eb857b344fa3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 15:42:24 +0200 Subject: [PATCH 18/33] . --- sentry_sdk/integrations/pydantic_ai/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 26d5a614f0..4245563639 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -84,7 +84,10 @@ async def on_request( model=request_context.model, model_settings=request_context.model_settings, ) - ctx.metadata["_sentry_span"] = span + run_context_metadata = ctx.metadata + if isinstance(run_context_metadata, dict): + run_context_metadata["_sentry_span"] = span + span.__enter__() return request_context @@ -96,13 +99,17 @@ async def on_response( request_context: "ModelRequestContext", response: "ModelResponse", ) -> "ModelResponse": - span = ctx.metadata["_sentry_span"] + run_context_metadata = ctx.metadata + if not isinstance(run_context_metadata, dict): + return response + + span = run_context_metadata["_sentry_span"] if span is None: return response update_ai_client_span(span, response) span.__exit__(None, None, None) - del ctx.metadata["_sentry_span"] + del run_context_metadata["_sentry_span"] return response From 9a02158c0ba1977b322d119f9e69375e762c9958 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 15:45:22 +0200 Subject: [PATCH 19/33] restore image url tests --- .../pydantic_ai/test_pydantic_ai.py | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index cc2f6d0b4c..e64a6d3e52 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -2834,9 +2834,8 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): assert span_data["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE] == 20 -@pytest.mark.asyncio @pytest.mark.parametrize( - "url, image_url_kwargs, expected_content", + "url,image_url_kwargs,expected_content", [ pytest.param( "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", @@ -2876,6 +2875,76 @@ async def test_set_usage_data_with_cache_tokens(sentry_init, capture_events): ), ], ) +def test_image_url_base64_content_in_span( + sentry_init, capture_events, url, image_url_kwargs, expected_content +): + from sentry_sdk.integrations.pydantic_ai.spans.ai_client import ai_client_span + + 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"): + image_url = ImageUrl(url=url, **image_url_kwargs) + user_part = UserPromptPart(content=["Look at this image:", image_url]) + mock_msg = MagicMock() + mock_msg.parts = [user_part] + mock_msg.instructions = None + + span = ai_client_span([mock_msg], None, None, None) + span.finish() + + (event,) = events + chat_spans = [s for s in event["spans"] if s["op"] == "gen_ai.chat"] + assert len(chat_spans) >= 1 + messages_data = _get_messages_from_span(chat_spans[0]["data"]) + + found_image = False + for msg in messages_data: + if "content" not in msg: + continue + for content_item in msg["content"]: + if content_item.get("type") == "image": + found_image = True + assert content_item["content"] == expected_content + + assert found_image, "Image content item should be found in messages data" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "url, image_url_kwargs, expected_content", + [ + pytest.param( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", + {}, + BLOB_DATA_SUBSTITUTE, + id="base64_data_url_redacted", + ), + pytest.param( + "https://example.com/image.png", + {}, + "https://example.com/image.png", + id="http_url_no_redaction", + ), + pytest.param( + "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", + {}, + "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", + id="http_url_with_base64_query_param", + ), + pytest.param( + "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", + {"media_type": "image/png"}, + "https://example.com/api?data=iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs", + id="http_url_with_base64_query_param_and_media_type", + ), + ], +) async def test_invoke_agent_image_url( sentry_init, capture_events, url, image_url_kwargs, expected_content ): From 7cf1dc80b2544a27974e1d781480ffb49dd13701 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 15:53:43 +0200 Subject: [PATCH 20/33] add error hook --- .../integrations/pydantic_ai/__init__.py | 18 +++++++++++++++++- .../pydantic_ai/patches/agent_run.py | 8 ++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 4245563639..0b84f01401 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -62,7 +62,6 @@ def setup_once() -> None: from pydantic_ai.capabilities import Hooks # type: ignore except ImportError: Hooks = None - # Save to populate ctx.metadata PydanticAIIntegration.are_request_hooks_available = True if Hooks is None: @@ -72,6 +71,9 @@ def setup_once() -> None: _patch_tool_execution() + # Assumptions: + # - Model requests within a run are sequential. + # - ctx.metadata is a shared dict instance between hooks. hooks = Hooks() @hooks.on.before_model_request # type: ignore @@ -113,6 +115,20 @@ async def on_response( return response + @hooks.on.model_request_error # type: ignore + async def on_error( + ctx: "RunContext[None]", + *, + request_context: "ModelRequestContext", + error: "Exception", + ) -> "ModelResponse": + run_context_metadata = ctx.metadata + if isinstance(run_context_metadata, dict): + span = run_context_metadata.pop("_sentry_span", None) + if span is not None: + span.__exit__(type(error), error, error.__traceback__) + raise error + original_init = Agent.__init__ @functools.wraps(original_init) diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index a21732c26b..df0cec07e2 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -96,7 +96,9 @@ def _create_run_wrapper( original_func: The original run method is_streaming: Whether this is a streaming method (for future use) """ - from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration + from sentry_sdk.integrations.pydantic_ai import ( + PydanticAIIntegration, + ) # Required to avoid circular import @wraps(original_func) async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -146,7 +148,9 @@ def _create_streaming_wrapper( """ Wraps run_stream method that returns an async context manager. """ - from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration + from sentry_sdk.integrations.pydantic_ai import ( + PydanticAIIntegration, + ) # Required to avoid circular import @wraps(original_func) def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": From b5b4d63d86db61a9a7591a1bbb7ec5db304eba74 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 8 Apr 2026 16:02:14 +0200 Subject: [PATCH 21/33] flip bool --- sentry_sdk/integrations/pydantic_ai/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 0b84f01401..7b6efb7ff1 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -62,7 +62,7 @@ def setup_once() -> None: from pydantic_ai.capabilities import Hooks # type: ignore except ImportError: Hooks = None - PydanticAIIntegration.are_request_hooks_available = True + PydanticAIIntegration.are_request_hooks_available = False if Hooks is None: _patch_graph_nodes() From d0cb35bf98015f97f66032167541ef3fea8a1287 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 10:28:19 +0200 Subject: [PATCH 22/33] . --- sentry_sdk/integrations/pydantic_ai/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 7b6efb7ff1..ce0f922bae 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -57,6 +57,7 @@ def setup_once() -> None: - Tool executions """ _patch_agent_run() + _patch_tool_execution() try: from pydantic_ai.capabilities import Hooks # type: ignore @@ -69,11 +70,9 @@ def setup_once() -> None: _patch_model_request() return - _patch_tool_execution() - # Assumptions: # - Model requests within a run are sequential. - # - ctx.metadata is a shared dict instance between hooks. + # - ctx.metadata is a shared dictionary instance between hooks. hooks = Hooks() @hooks.on.before_model_request # type: ignore From 87bfb086cc20cf76239051825af027c99d036c1f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:20:48 +0200 Subject: [PATCH 23/33] document --- .../integrations/pydantic_ai/__init__.py | 163 ++++++++++-------- .../pydantic_ai/test_pydantic_ai.py | 60 +++++++ 2 files changed, 154 insertions(+), 69 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index ce0f922bae..199c156db3 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -1,6 +1,7 @@ import functools from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.utils import capture_internal_exceptions try: import pydantic_ai # type: ignore # noqa: F401 @@ -24,9 +25,101 @@ from typing import Any from pydantic_ai import ModelRequestContext, RunContext from pydantic_ai.messages import ModelResponse # type: ignore + from pydantic_ai.capabilities import Hooks # type: ignore + + +def register_hooks(hooks: "Hooks"): + """ + Creates hooks for chat model calls and register the hooks by adding the hooks to the `capabilities` argument passed to `Agent.__init__()`. + """ + + @hooks.on.before_model_request # type: ignore + async def on_request( + ctx: "RunContext[None]", request_context: "ModelRequestContext" + ) -> "ModelRequestContext": + span = ai_client_span( + messages=request_context.messages, + agent=None, + model=request_context.model, + model_settings=request_context.model_settings, + ) + run_context_metadata = ctx.metadata + if isinstance(run_context_metadata, dict): + run_context_metadata["_sentry_span"] = span + + span.__enter__() + + return request_context + + @hooks.on.after_model_request # type: ignore + async def on_response( + ctx: "RunContext[None]", + *, + request_context: "ModelRequestContext", + response: "ModelResponse", + ) -> "ModelResponse": + run_context_metadata = ctx.metadata + if not isinstance(run_context_metadata, dict): + return response + + span = run_context_metadata.pop("_sentry_span", None) + if span is None: + return response + + update_ai_client_span(span, response) + span.__exit__(None, None, None) + + return response + + @hooks.on.model_request_error # type: ignore + async def on_error( + ctx: "RunContext[None]", + *, + request_context: "ModelRequestContext", + error: "Exception", + ) -> "ModelResponse": + run_context_metadata = ctx.metadata + + if not isinstance(run_context_metadata, dict): + raise error + + span = run_context_metadata.pop("_sentry_span", None) + if span is None: + raise error + + with capture_internal_exceptions(): + span.__exit__(type(error), error, error.__traceback__) + + raise error + + original_init = Agent.__init__ + + @functools.wraps(original_init) + def patched_init(self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any") -> None: + caps = list(kwargs.get("capabilities") or []) + caps.append(hooks) + kwargs["capabilities"] = caps + return original_init(self, *args, **kwargs) + + Agent.__init__ = patched_init class PydanticAIIntegration(Integration): + """ + Typical interaction with the library: + 1. The user creates an Agent instance with configuration, including system instructions sent to every model call. + 2. The user calls `Agent.run()` or `Agent.run_stream()` to start an agent run. The latter can be used to incrementally receive progress. + - Each run invocation has `RunContext` objects that are passed to the library hooks. + 3. In a loop, the agent repeatedly calls the model, maintaining a conversation history that includes previous messages and tool results, which is passed to each call. + + Internally, Pydantic AI maintains an execution graph in which ModelRequestNode are responsible for model calls, including retries. + Hooks created with the decorators provided by `pydantic_ai.capabilities` are used to create spans for model calls when these hooks are available (newer library versions). + The span is created in `on_request` and stored in the metadata of the shared `RunContext` object that is passed to `on_response` and `on_error`. + + The metadata dictionary on the RunContext instance is initialized with `{"_sentry_span": None}` in the `_create_run_wrapper()` and `_create_streaming_wrapper()` wrappers that + instrument `Agent.run()` and `Agent.run_stream()`, respectively. A non-empty dictionary is required for the metadata object to be a shared reference between hooks. + """ + identifier = "pydantic_ai" origin = f"auto.ai.{identifier}" are_request_hooks_available = True @@ -70,73 +163,5 @@ def setup_once() -> None: _patch_model_request() return - # Assumptions: - # - Model requests within a run are sequential. - # - ctx.metadata is a shared dictionary instance between hooks. hooks = Hooks() - - @hooks.on.before_model_request # type: ignore - async def on_request( - ctx: "RunContext[None]", request_context: "ModelRequestContext" - ) -> "ModelRequestContext": - span = ai_client_span( - messages=request_context.messages, - agent=None, - model=request_context.model, - model_settings=request_context.model_settings, - ) - run_context_metadata = ctx.metadata - if isinstance(run_context_metadata, dict): - run_context_metadata["_sentry_span"] = span - - span.__enter__() - - return request_context - - @hooks.on.after_model_request # type: ignore - async def on_response( - ctx: "RunContext[None]", - *, - request_context: "ModelRequestContext", - response: "ModelResponse", - ) -> "ModelResponse": - run_context_metadata = ctx.metadata - if not isinstance(run_context_metadata, dict): - return response - - span = run_context_metadata["_sentry_span"] - if span is None: - return response - - update_ai_client_span(span, response) - span.__exit__(None, None, None) - del run_context_metadata["_sentry_span"] - - return response - - @hooks.on.model_request_error # type: ignore - async def on_error( - ctx: "RunContext[None]", - *, - request_context: "ModelRequestContext", - error: "Exception", - ) -> "ModelResponse": - run_context_metadata = ctx.metadata - if isinstance(run_context_metadata, dict): - span = run_context_metadata.pop("_sentry_span", None) - if span is not None: - span.__exit__(type(error), error, error.__traceback__) - raise error - - original_init = Agent.__init__ - - @functools.wraps(original_init) - def patched_init( - self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any" - ) -> None: - caps = list(kwargs.get("capabilities") or []) - caps.append(hooks) - kwargs["capabilities"] = caps - return original_init(self, *args, **kwargs) - - Agent.__init__ = patched_init + register_hooks(hooks) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index e64a6d3e52..a3d418ff38 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -16,6 +16,7 @@ from pydantic_ai.messages import BinaryContent, ImageUrl, UserPromptPart from pydantic_ai.usage import RequestUsage from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior +from pydantic_ai.models.function import FunctionModel @pytest.fixture @@ -94,6 +95,35 @@ async def test_agent_run_async(sentry_init, capture_events, get_test_agent): assert "gen_ai.usage.output_tokens" in chat_span["data"] +@pytest.mark.asyncio +async def test_agent_run_async_model_error(sentry_init, capture_events): + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + ) + + events = capture_events() + + def failing_model(messages, info): + raise RuntimeError("model exploded") + + agent = Agent( + FunctionModel(failing_model), + name="test_agent", + ) + + with pytest.raises(RuntimeError, match="model exploded"): + await agent.run("Test input") + + (error, transaction) = events + assert error["level"] == "error" + + spans = transaction["spans"] + assert len(spans) == 1 + + assert spans[0]["status"] == "internal_error" + + @pytest.mark.asyncio async def test_agent_run_async_usage_data(sentry_init, capture_events, get_test_agent): """ @@ -174,6 +204,36 @@ def test_agent_run_sync(sentry_init, capture_events, get_test_agent): assert chat_span["data"]["gen_ai.response.streaming"] is False +@pytest.mark.asyncio +async def test_agent_run_sync_model_error(sentry_init, capture_events): + sentry_init( + integrations=[PydanticAIIntegration()], + traces_sample_rate=1.0, + ) + + events = capture_events() + + def failing_model(messages, info): + raise RuntimeError("model exploded") + + agent = Agent( + FunctionModel(failing_model), + name="test_agent", + ) + + with pytest.raises(RuntimeError, match="model exploded"): + await agent.run("Test input") + + print("events", len(events)) + (error, transaction) = events + assert error["level"] == "error" + + spans = transaction["spans"] + assert len(spans) == 1 + + assert spans[0]["status"] == "internal_error" + + @pytest.mark.asyncio async def test_agent_run_stream(sentry_init, capture_events, get_test_agent): """ From 80c2ee6f1d6f3be2f914d96f50438d48eb0d99c8 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:22:41 +0200 Subject: [PATCH 24/33] document 2 --- sentry_sdk/integrations/pydantic_ai/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 199c156db3..70f6dbb3ae 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -113,8 +113,8 @@ class PydanticAIIntegration(Integration): 3. In a loop, the agent repeatedly calls the model, maintaining a conversation history that includes previous messages and tool results, which is passed to each call. Internally, Pydantic AI maintains an execution graph in which ModelRequestNode are responsible for model calls, including retries. - Hooks created with the decorators provided by `pydantic_ai.capabilities` are used to create spans for model calls when these hooks are available (newer library versions). - The span is created in `on_request` and stored in the metadata of the shared `RunContext` object that is passed to `on_response` and `on_error`. + Hooks using the decorators provided by `pydantic_ai.capabilities` create and manage spans for model calls when these hooks are available (newer library versions). + The span is created in `on_request` and stored in the metadata of the `RunContext` object shared with `on_response` and `on_error`. The metadata dictionary on the RunContext instance is initialized with `{"_sentry_span": None}` in the `_create_run_wrapper()` and `_create_streaming_wrapper()` wrappers that instrument `Agent.run()` and `Agent.run_stream()`, respectively. A non-empty dictionary is required for the metadata object to be a shared reference between hooks. From f84ffaea3769a8dba28fb8c860ab96fc39e98ab6 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:25:47 +0200 Subject: [PATCH 25/33] linting --- sentry_sdk/integrations/pydantic_ai/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 70f6dbb3ae..2b3e314fb6 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -28,7 +28,7 @@ from pydantic_ai.capabilities import Hooks # type: ignore -def register_hooks(hooks: "Hooks"): +def register_hooks(hooks: "Hooks") -> None: """ Creates hooks for chat model calls and register the hooks by adding the hooks to the `capabilities` argument passed to `Agent.__init__()`. """ @@ -153,7 +153,7 @@ def setup_once() -> None: _patch_tool_execution() try: - from pydantic_ai.capabilities import Hooks # type: ignore + from pydantic_ai.capabilities import Hooks except ImportError: Hooks = None PydanticAIIntegration.are_request_hooks_available = False From 2fb567ca089230dd5e58eab1b34026025ba0d526 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:33:01 +0200 Subject: [PATCH 26/33] fix run sync test --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index a3d418ff38..8451f652b4 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -222,7 +222,7 @@ def failing_model(messages, info): ) with pytest.raises(RuntimeError, match="model exploded"): - await agent.run("Test input") + agent.run_sync("Test input") print("events", len(events)) (error, transaction) = events From 1059be99e894c6dbdebe412cd0cc643d873d463f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:40:05 +0200 Subject: [PATCH 27/33] update --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 8451f652b4..82f39bddce 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -204,8 +204,7 @@ def test_agent_run_sync(sentry_init, capture_events, get_test_agent): assert chat_span["data"]["gen_ai.response.streaming"] is False -@pytest.mark.asyncio -async def test_agent_run_sync_model_error(sentry_init, capture_events): +def test_agent_run_sync_model_error(sentry_init, capture_events): sentry_init( integrations=[PydanticAIIntegration()], traces_sample_rate=1.0, From 2606eeff7fa9ba55726e4bccb57657c3287c7ce8 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:41:08 +0200 Subject: [PATCH 28/33] use early return --- sentry_sdk/integrations/pydantic_ai/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 2b3e314fb6..109196f798 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -37,16 +37,18 @@ def register_hooks(hooks: "Hooks") -> None: async def on_request( ctx: "RunContext[None]", request_context: "ModelRequestContext" ) -> "ModelRequestContext": + run_context_metadata = ctx.metadata + if not isinstance(run_context_metadata, dict): + return request_context + span = ai_client_span( messages=request_context.messages, agent=None, model=request_context.model, model_settings=request_context.model_settings, ) - run_context_metadata = ctx.metadata - if isinstance(run_context_metadata, dict): - run_context_metadata["_sentry_span"] = span + run_context_metadata["_sentry_span"] = span span.__enter__() return request_context From 3452e521f792773202bde4b44a88d193f3cc84d2 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:51:31 +0200 Subject: [PATCH 29/33] remove print from test --- tests/integrations/pydantic_ai/test_pydantic_ai.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 82f39bddce..d941e793f3 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -223,7 +223,6 @@ def failing_model(messages, info): with pytest.raises(RuntimeError, match="model exploded"): agent.run_sync("Test input") - print("events", len(events)) (error, transaction) = events assert error["level"] == "error" From 396f322165984aa6074eb00f89773b57b52b0eab Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 15:59:55 +0200 Subject: [PATCH 30/33] set metadata in agent.__init__ --- sentry_sdk/integrations/pydantic_ai/__init__.py | 11 +++++++---- .../pydantic_ai/patches/agent_run.py | 16 ---------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 109196f798..38e166a064 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -101,6 +101,11 @@ def patched_init(self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any") -> None caps = list(kwargs.get("capabilities") or []) caps.append(hooks) kwargs["capabilities"] = caps + + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {} # Used as shared reference between hooks + return original_init(self, *args, **kwargs) Agent.__init__ = patched_init @@ -118,13 +123,12 @@ class PydanticAIIntegration(Integration): Hooks using the decorators provided by `pydantic_ai.capabilities` create and manage spans for model calls when these hooks are available (newer library versions). The span is created in `on_request` and stored in the metadata of the `RunContext` object shared with `on_response` and `on_error`. - The metadata dictionary on the RunContext instance is initialized with `{"_sentry_span": None}` in the `_create_run_wrapper()` and `_create_streaming_wrapper()` wrappers that - instrument `Agent.run()` and `Agent.run_stream()`, respectively. A non-empty dictionary is required for the metadata object to be a shared reference between hooks. + The metadata on the RunContext instance is initialized with an empty dictionary in `Agent.__init__()`. The dictionary is required for the metadata object + to be a shared reference between hooks. """ identifier = "pydantic_ai" origin = f"auto.ai.{identifier}" - are_request_hooks_available = True def __init__( self, include_prompts: bool = True, handled_tool_call_exceptions: bool = True @@ -158,7 +162,6 @@ def setup_once() -> None: from pydantic_ai.capabilities import Hooks except ImportError: Hooks = None - PydanticAIIntegration.are_request_hooks_available = False if Hooks is None: _patch_graph_nodes() diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index df0cec07e2..eaa4385834 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -96,9 +96,6 @@ def _create_run_wrapper( original_func: The original run method is_streaming: Whether this is a streaming method (for future use) """ - from sentry_sdk.integrations.pydantic_ai import ( - PydanticAIIntegration, - ) # Required to avoid circular import @wraps(original_func) async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -110,11 +107,6 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") - if PydanticAIIntegration.are_request_hooks_available: - metadata = kwargs.get("metadata") - if not metadata: - kwargs["metadata"] = {"_sentry_span": None} - # Create invoke_agent span with invoke_agent_span( user_prompt, self, model, model_settings, is_streaming @@ -148,9 +140,6 @@ def _create_streaming_wrapper( """ Wraps run_stream method that returns an async context manager. """ - from sentry_sdk.integrations.pydantic_ai import ( - PydanticAIIntegration, - ) # Required to avoid circular import @wraps(original_func) def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -159,11 +148,6 @@ def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") - if PydanticAIIntegration.are_request_hooks_available: - metadata = kwargs.get("metadata") - if not metadata: - kwargs["metadata"] = {"_sentry_span": None} - # Call original function to get the context manager original_ctx_manager = original_func(self, *args, **kwargs) From 622a48f640a7fcbc4c83c76dcb11df80a36236ca Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 16:01:59 +0200 Subject: [PATCH 31/33] docstring --- sentry_sdk/integrations/pydantic_ai/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 38e166a064..787933b13b 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -123,8 +123,8 @@ class PydanticAIIntegration(Integration): Hooks using the decorators provided by `pydantic_ai.capabilities` create and manage spans for model calls when these hooks are available (newer library versions). The span is created in `on_request` and stored in the metadata of the `RunContext` object shared with `on_response` and `on_error`. - The metadata on the RunContext instance is initialized with an empty dictionary in `Agent.__init__()`. The dictionary is required for the metadata object - to be a shared reference between hooks. + The metadata on the RunContext instance is initialized with an empty dictionary in `Agent.__init__()` if no metadata dictionary is provided by the user. The dictionary is + required for the metadata object to be a shared reference between hooks. """ identifier = "pydantic_ai" From b72be0fef343ce89a9134d4304de325a1c72944b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 16:02:49 +0200 Subject: [PATCH 32/33] check none instead of falsy --- sentry_sdk/integrations/pydantic_ai/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 787933b13b..46273e577c 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -103,7 +103,7 @@ def patched_init(self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any") -> None kwargs["capabilities"] = caps metadata = kwargs.get("metadata") - if not metadata: + if metadata is None: kwargs["metadata"] = {} # Used as shared reference between hooks return original_init(self, *args, **kwargs) From ef7802a77313af1c13a27f0cdd4c7ccdf5796c66 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 9 Apr 2026 16:22:50 +0200 Subject: [PATCH 33/33] create metadata dict in agent run methods again --- sentry_sdk/integrations/pydantic_ai/__init__.py | 6 ++++-- .../pydantic_ai/patches/agent_run.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/__init__.py b/sentry_sdk/integrations/pydantic_ai/__init__.py index 46273e577c..648f6503ec 100644 --- a/sentry_sdk/integrations/pydantic_ai/__init__.py +++ b/sentry_sdk/integrations/pydantic_ai/__init__.py @@ -123,12 +123,13 @@ class PydanticAIIntegration(Integration): Hooks using the decorators provided by `pydantic_ai.capabilities` create and manage spans for model calls when these hooks are available (newer library versions). The span is created in `on_request` and stored in the metadata of the `RunContext` object shared with `on_response` and `on_error`. - The metadata on the RunContext instance is initialized with an empty dictionary in `Agent.__init__()` if no metadata dictionary is provided by the user. The dictionary is - required for the metadata object to be a shared reference between hooks. + The metadata dictionary on the RunContext instance is initialized with `{"_sentry_span": None}` in the `_create_run_wrapper()` and `_create_streaming_wrapper()` wrappers that + instrument `Agent.run()` and `Agent.run_stream()`, respectively. A non-empty dictionary is required for the metadata object to be a shared reference between hooks. """ identifier = "pydantic_ai" origin = f"auto.ai.{identifier}" + are_request_hooks_available = True def __init__( self, include_prompts: bool = True, handled_tool_call_exceptions: bool = True @@ -162,6 +163,7 @@ def setup_once() -> None: from pydantic_ai.capabilities import Hooks except ImportError: Hooks = None + PydanticAIIntegration.are_request_hooks_available = False if Hooks is None: _patch_graph_nodes() diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index eaa4385834..df0cec07e2 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -96,6 +96,9 @@ def _create_run_wrapper( original_func: The original run method is_streaming: Whether this is a streaming method (for future use) """ + from sentry_sdk.integrations.pydantic_ai import ( + PydanticAIIntegration, + ) # Required to avoid circular import @wraps(original_func) async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -107,6 +110,11 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") + if PydanticAIIntegration.are_request_hooks_available: + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {"_sentry_span": None} + # Create invoke_agent span with invoke_agent_span( user_prompt, self, model, model_settings, is_streaming @@ -140,6 +148,9 @@ def _create_streaming_wrapper( """ Wraps run_stream method that returns an async context manager. """ + from sentry_sdk.integrations.pydantic_ai import ( + PydanticAIIntegration, + ) # Required to avoid circular import @wraps(original_func) def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": @@ -148,6 +159,11 @@ def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": model = kwargs.get("model") model_settings = kwargs.get("model_settings") + if PydanticAIIntegration.are_request_hooks_available: + metadata = kwargs.get("metadata") + if not metadata: + kwargs["metadata"] = {"_sentry_span": None} + # Call original function to get the context manager original_ctx_manager = original_func(self, *args, **kwargs)