Skip to content

Commit 3a04323

Browse files
use hooks
1 parent cb41f97 commit 3a04323

File tree

9 files changed

+373
-286
lines changed

9 files changed

+373
-286
lines changed

sentry_sdk/integrations/pydantic_ai/__init__.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from sentry_sdk.integrations import DidNotEnable, Integration
1+
import functools
22

3+
from sentry_sdk.integrations import DidNotEnable, Integration
34

45
try:
56
import pydantic_ai # type: ignore # noqa: F401
7+
from pydantic_ai import Agent
68
except ImportError:
79
raise DidNotEnable("pydantic-ai not installed")
810

@@ -14,6 +16,14 @@
1416
_patch_tool_execution,
1517
)
1618

19+
from .spans.ai_client import ai_client_span, update_ai_client_span
20+
21+
from typing import TYPE_CHECKING
22+
23+
if TYPE_CHECKING:
24+
from pydantic_ai import ModelRequestContext, RunContext
25+
from pydantic_ai.messages import ModelResponse
26+
1727

1828
class PydanticAIIntegration(Integration):
1929
identifier = "pydantic_ai"
@@ -45,6 +55,57 @@ def setup_once() -> None:
4555
- Tool executions
4656
"""
4757
_patch_agent_run()
48-
_patch_graph_nodes()
49-
_patch_model_request()
58+
59+
try:
60+
from pydantic_ai.capabilities import Hooks
61+
62+
hooks = Hooks()
63+
64+
@hooks.on.before_model_request
65+
async def on_request(
66+
ctx: "RunContext[None]", request_context: "ModelRequestContext"
67+
) -> "ModelRequestContext":
68+
span = ai_client_span(
69+
messages=request_context.messages,
70+
agent=None,
71+
model=request_context.model,
72+
model_settings=request_context.model_settings,
73+
)
74+
ctx.metadata["_sentry_span"] = span
75+
span.__enter__()
76+
77+
return request_context
78+
79+
@hooks.on.after_model_request
80+
async def on_response(
81+
ctx: "RunContext[None]",
82+
*,
83+
request_context: "ModelRequestContext",
84+
response: "ModelResponse",
85+
) -> "ModelResponse":
86+
span = ctx.metadata["_sentry_span"]
87+
if span is None:
88+
return response
89+
90+
update_ai_client_span(span, response)
91+
span.__exit__(None, None, None)
92+
del ctx.metadata["_sentry_span"]
93+
94+
return response
95+
96+
original_init = Agent.__init__
97+
98+
@functools.wraps(original_init)
99+
def patched_init(self, *args, **kwargs):
100+
caps = list(kwargs.get("capabilities") or [])
101+
caps.append(hooks)
102+
kwargs["capabilities"] = caps
103+
original_init(self, *args, **kwargs)
104+
105+
Agent.__init__ = patched_init
106+
107+
except ImportError:
108+
_patch_graph_nodes()
109+
_patch_model_request()
110+
50111
_patch_tool_execution()

sentry_sdk/integrations/pydantic_ai/patches/agent_run.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
107107
model = kwargs.get("model")
108108
model_settings = kwargs.get("model_settings")
109109

110+
metadata = kwargs.get("metadata")
111+
if not metadata:
112+
kwargs["metadata"] = {"_sentry_span": None}
113+
110114
# Create invoke_agent span
111115
with invoke_agent_span(
112116
user_prompt, self, model, model_settings, is_streaming
@@ -148,6 +152,10 @@ def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
148152
model = kwargs.get("model")
149153
model_settings = kwargs.get("model_settings")
150154

155+
metadata = kwargs.get("metadata")
156+
if not metadata:
157+
kwargs["metadata"] = {"_sentry_span": None}
158+
151159
# Call original function to get the context manager
152160
original_ctx_manager = original_func(self, *args, **kwargs)
153161

sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
ai_client_span,
88
update_ai_client_span,
99
)
10-
from ..utils import _set_input_messages
1110

1211
try:
1312
from pydantic_ai._agent_graph import ModelRequestNode # type: ignore
@@ -60,15 +59,9 @@ def _patch_graph_nodes() -> None:
6059
async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any":
6160
messages, model, model_settings = _extract_span_data(self, ctx)
6261

63-
with ai_client_span(None, model, model_settings) as span:
62+
with ai_client_span(messages, None, model, model_settings) as span:
6463
result = await original_model_request_run(self, ctx)
6564

66-
# The instructions are added in `_prepare_request` that runs as part of `ModelRequestNode.run`, so the input
67-
# must be recorded after the call. See _get_instructions() added with
68-
# https://github.com/pydantic/pydantic-ai/commit/f5271434a56c7a3bb5a3c93f2d1236d8b18afe3e
69-
if messages:
70-
_set_input_messages(span, messages)
71-
7265
# Extract response from result if available
7366
model_response = None
7467
if hasattr(result, "model_response"):
@@ -93,16 +86,9 @@ async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any":
9386
messages, model, model_settings = _extract_span_data(self, ctx)
9487

9588
# Create chat span for streaming request
96-
with ai_client_span(None, model, model_settings) as span:
89+
with ai_client_span(messages, None, model, model_settings) as span:
9790
# Call the original stream method
9891
async with original_stream_method(self, ctx) as stream:
99-
# The instructions are added in `_prepare_request` that runs as part of __aenter__ on the
100-
# context manager returned by `ModelRequestNode.stream()`, so the input must be recorded after the
101-
# call. See _get_instructions() added with
102-
# https://github.com/pydantic/pydantic-ai/commit/f5271434a56c7a3bb5a3c93f2d1236d8b18afe3e
103-
if messages:
104-
_set_input_messages(span, messages)
105-
10692
yield stream
10793

10894
# After streaming completes, update span with response data

sentry_sdk/integrations/pydantic_ai/patches/model_request.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
from sentry_sdk.integrations import DidNotEnable
55

6-
from ..utils import _set_input_messages
7-
86
try:
97
from pydantic_ai import models # type: ignore
108
except ImportError:
@@ -34,10 +32,7 @@ async def wrapped_request(
3432
self: "Any", messages: "Any", *args: "Any", **kwargs: "Any"
3533
) -> "Any":
3634
# Pass all messages (full conversation history)
37-
with ai_client_span(None, self, None) as span:
38-
if messages:
39-
_set_input_messages(span, messages)
40-
35+
with ai_client_span(messages, None, self, None) as span:
4136
result = await original_request(self, messages, *args, **kwargs)
4237
update_ai_client_span(span, result)
4338
return result

sentry_sdk/integrations/pydantic_ai/spans/ai_client.py

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import json
2+
13
import sentry_sdk
24
from sentry_sdk.ai.utils import (
5+
normalize_message_roles,
36
set_data_normalized,
7+
truncate_and_annotate_messages,
48
)
59
from sentry_sdk.consts import OP, SPANDATA
610
from sentry_sdk.utils import safe_serialize
@@ -16,23 +20,178 @@
1620
get_is_streaming,
1721
)
1822
from .utils import (
23+
_serialize_binary_content_item,
24+
_serialize_image_url_item,
1925
_set_usage_data,
2026
)
2127

2228
from typing import TYPE_CHECKING
2329

2430
if TYPE_CHECKING:
25-
from typing import Any
31+
from typing import Any, List, Dict
32+
from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore
33+
from sentry_sdk._types import TextPart as SentryTextPart
2634

2735
try:
28-
from pydantic_ai.messages import ( # type: ignore
36+
from pydantic_ai.messages import (
2937
BaseToolCallPart,
38+
BaseToolReturnPart,
39+
SystemPromptPart,
40+
UserPromptPart,
3041
TextPart,
42+
ThinkingPart,
43+
BinaryContent,
44+
ImageUrl,
3145
)
3246
except ImportError:
3347
# Fallback if these classes are not available
3448
BaseToolCallPart = None
49+
BaseToolReturnPart = None
50+
SystemPromptPart = None
51+
UserPromptPart = None
3552
TextPart = None
53+
ThinkingPart = None
54+
BinaryContent = None
55+
ImageUrl = None
56+
57+
58+
def _transform_system_instructions(
59+
permanent_instructions: "list[SystemPromptPart]",
60+
current_instructions: "list[str]",
61+
) -> "list[SentryTextPart]":
62+
text_parts: "list[SentryTextPart]" = [
63+
{
64+
"type": "text",
65+
"content": instruction.content,
66+
}
67+
for instruction in permanent_instructions
68+
]
69+
70+
text_parts.extend(
71+
{
72+
"type": "text",
73+
"content": instruction,
74+
}
75+
for instruction in current_instructions
76+
)
77+
78+
return text_parts
79+
80+
81+
def _get_system_instructions(
82+
messages: "list[ModelMessage]",
83+
) -> "tuple[list[SystemPromptPart], list[str]]":
84+
permanent_instructions = []
85+
current_instructions = []
86+
87+
for msg in messages:
88+
if hasattr(msg, "parts"):
89+
for part in msg.parts:
90+
if SystemPromptPart and isinstance(part, SystemPromptPart):
91+
permanent_instructions.append(part)
92+
93+
if hasattr(msg, "instructions") and msg.instructions is not None:
94+
current_instructions.append(msg.instructions)
95+
96+
return permanent_instructions, current_instructions
97+
98+
99+
def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
100+
"""Set input messages data on a span."""
101+
if not _should_send_prompts():
102+
return
103+
104+
if not messages:
105+
return
106+
107+
permanent_instructions, current_instructions = _get_system_instructions(messages)
108+
if len(permanent_instructions) > 0 or len(current_instructions) > 0:
109+
span.set_data(
110+
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
111+
json.dumps(
112+
_transform_system_instructions(
113+
permanent_instructions, current_instructions
114+
)
115+
),
116+
)
117+
118+
try:
119+
formatted_messages = []
120+
121+
for msg in messages:
122+
if hasattr(msg, "parts"):
123+
for part in msg.parts:
124+
role = "user"
125+
# Use isinstance checks with proper base classes
126+
if SystemPromptPart and isinstance(part, SystemPromptPart):
127+
continue
128+
elif (
129+
(TextPart and isinstance(part, TextPart))
130+
or (ThinkingPart and isinstance(part, ThinkingPart))
131+
or (BaseToolCallPart and isinstance(part, BaseToolCallPart))
132+
):
133+
role = "assistant"
134+
elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart):
135+
role = "tool"
136+
137+
content: "List[Dict[str, Any] | str]" = []
138+
tool_calls = None
139+
tool_call_id = None
140+
141+
# Handle ToolCallPart (assistant requesting tool use)
142+
if BaseToolCallPart and isinstance(part, BaseToolCallPart):
143+
tool_call_data = {}
144+
if hasattr(part, "tool_name"):
145+
tool_call_data["name"] = part.tool_name
146+
if hasattr(part, "args"):
147+
tool_call_data["arguments"] = safe_serialize(part.args)
148+
if tool_call_data:
149+
tool_calls = [tool_call_data]
150+
# Handle ToolReturnPart (tool result)
151+
elif BaseToolReturnPart and isinstance(part, BaseToolReturnPart):
152+
if hasattr(part, "tool_name"):
153+
tool_call_id = part.tool_name
154+
if hasattr(part, "content"):
155+
content.append({"type": "text", "text": str(part.content)})
156+
# Handle regular content
157+
elif hasattr(part, "content"):
158+
if isinstance(part.content, str):
159+
content.append({"type": "text", "text": part.content})
160+
elif isinstance(part.content, list):
161+
for item in part.content:
162+
if isinstance(item, str):
163+
content.append({"type": "text", "text": item})
164+
elif ImageUrl and isinstance(item, ImageUrl):
165+
content.append(_serialize_image_url_item(item))
166+
elif BinaryContent and isinstance(item, BinaryContent):
167+
content.append(_serialize_binary_content_item(item))
168+
else:
169+
content.append(safe_serialize(item))
170+
else:
171+
content.append({"type": "text", "text": str(part.content)})
172+
# Add message if we have content or tool calls
173+
if content or tool_calls:
174+
message: "Dict[str, Any]" = {"role": role}
175+
if content:
176+
message["content"] = content
177+
if tool_calls:
178+
message["tool_calls"] = tool_calls
179+
if tool_call_id:
180+
message["tool_call_id"] = tool_call_id
181+
formatted_messages.append(message)
182+
183+
if formatted_messages:
184+
normalized_messages = normalize_message_roles(formatted_messages)
185+
scope = sentry_sdk.get_current_scope()
186+
messages_data = truncate_and_annotate_messages(
187+
normalized_messages, span, scope
188+
)
189+
set_data_normalized(
190+
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False
191+
)
192+
except Exception:
193+
# If we fail to format messages, just skip it
194+
pass
36195

37196

38197
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:
77236

78237

79238
def ai_client_span(
80-
agent: "Any", model: "Any", model_settings: "Any"
239+
messages: "Any", agent: "Any", model: "Any", model_settings: "Any"
81240
) -> "sentry_sdk.tracing.Span":
82241
"""Create a span for an AI client call (model request).
83242
@@ -112,6 +271,10 @@ def ai_client_span(
112271
agent_obj = agent or get_current_agent()
113272
_set_available_tools(span, agent_obj)
114273

274+
# Set input messages (full conversation history)
275+
if messages:
276+
_set_input_messages(span, messages)
277+
115278
return span
116279

117280

sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
_set_available_tools,
1414
_set_model_data,
1515
_should_send_prompts,
16-
_serialize_binary_content_item,
17-
_serialize_image_url_item,
1816
)
1917
from .utils import (
18+
_serialize_binary_content_item,
19+
_serialize_image_url_item,
2020
_set_usage_data,
2121
)
2222

0 commit comments

Comments
 (0)