Skip to content

Commit 87bfb08

Browse files
document
1 parent d0cb35b commit 87bfb08

File tree

2 files changed

+154
-69
lines changed

2 files changed

+154
-69
lines changed
Lines changed: 94 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22

33
from sentry_sdk.integrations import DidNotEnable, Integration
4+
from sentry_sdk.utils import capture_internal_exceptions
45

56
try:
67
import pydantic_ai # type: ignore # noqa: F401
@@ -24,9 +25,101 @@
2425
from typing import Any
2526
from pydantic_ai import ModelRequestContext, RunContext
2627
from pydantic_ai.messages import ModelResponse # type: ignore
28+
from pydantic_ai.capabilities import Hooks # type: ignore
29+
30+
31+
def register_hooks(hooks: "Hooks"):
32+
"""
33+
Creates hooks for chat model calls and register the hooks by adding the hooks to the `capabilities` argument passed to `Agent.__init__()`.
34+
"""
35+
36+
@hooks.on.before_model_request # type: ignore
37+
async def on_request(
38+
ctx: "RunContext[None]", request_context: "ModelRequestContext"
39+
) -> "ModelRequestContext":
40+
span = ai_client_span(
41+
messages=request_context.messages,
42+
agent=None,
43+
model=request_context.model,
44+
model_settings=request_context.model_settings,
45+
)
46+
run_context_metadata = ctx.metadata
47+
if isinstance(run_context_metadata, dict):
48+
run_context_metadata["_sentry_span"] = span
49+
50+
span.__enter__()
51+
52+
return request_context
53+
54+
@hooks.on.after_model_request # type: ignore
55+
async def on_response(
56+
ctx: "RunContext[None]",
57+
*,
58+
request_context: "ModelRequestContext",
59+
response: "ModelResponse",
60+
) -> "ModelResponse":
61+
run_context_metadata = ctx.metadata
62+
if not isinstance(run_context_metadata, dict):
63+
return response
64+
65+
span = run_context_metadata.pop("_sentry_span", None)
66+
if span is None:
67+
return response
68+
69+
update_ai_client_span(span, response)
70+
span.__exit__(None, None, None)
71+
72+
return response
73+
74+
@hooks.on.model_request_error # type: ignore
75+
async def on_error(
76+
ctx: "RunContext[None]",
77+
*,
78+
request_context: "ModelRequestContext",
79+
error: "Exception",
80+
) -> "ModelResponse":
81+
run_context_metadata = ctx.metadata
82+
83+
if not isinstance(run_context_metadata, dict):
84+
raise error
85+
86+
span = run_context_metadata.pop("_sentry_span", None)
87+
if span is None:
88+
raise error
89+
90+
with capture_internal_exceptions():
91+
span.__exit__(type(error), error, error.__traceback__)
92+
93+
raise error
94+
95+
original_init = Agent.__init__
96+
97+
@functools.wraps(original_init)
98+
def patched_init(self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any") -> None:
99+
caps = list(kwargs.get("capabilities") or [])
100+
caps.append(hooks)
101+
kwargs["capabilities"] = caps
102+
return original_init(self, *args, **kwargs)
103+
104+
Agent.__init__ = patched_init
27105

28106

29107
class PydanticAIIntegration(Integration):
108+
"""
109+
Typical interaction with the library:
110+
1. The user creates an Agent instance with configuration, including system instructions sent to every model call.
111+
2. The user calls `Agent.run()` or `Agent.run_stream()` to start an agent run. The latter can be used to incrementally receive progress.
112+
- Each run invocation has `RunContext` objects that are passed to the library hooks.
113+
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.
114+
115+
Internally, Pydantic AI maintains an execution graph in which ModelRequestNode are responsible for model calls, including retries.
116+
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).
117+
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`.
118+
119+
The metadata dictionary on the RunContext instance is initialized with `{"_sentry_span": None}` in the `_create_run_wrapper()` and `_create_streaming_wrapper()` wrappers that
120+
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.
121+
"""
122+
30123
identifier = "pydantic_ai"
31124
origin = f"auto.ai.{identifier}"
32125
are_request_hooks_available = True
@@ -70,73 +163,5 @@ def setup_once() -> None:
70163
_patch_model_request()
71164
return
72165

73-
# Assumptions:
74-
# - Model requests within a run are sequential.
75-
# - ctx.metadata is a shared dictionary instance between hooks.
76166
hooks = Hooks()
77-
78-
@hooks.on.before_model_request # type: ignore
79-
async def on_request(
80-
ctx: "RunContext[None]", request_context: "ModelRequestContext"
81-
) -> "ModelRequestContext":
82-
span = ai_client_span(
83-
messages=request_context.messages,
84-
agent=None,
85-
model=request_context.model,
86-
model_settings=request_context.model_settings,
87-
)
88-
run_context_metadata = ctx.metadata
89-
if isinstance(run_context_metadata, dict):
90-
run_context_metadata["_sentry_span"] = span
91-
92-
span.__enter__()
93-
94-
return request_context
95-
96-
@hooks.on.after_model_request # type: ignore
97-
async def on_response(
98-
ctx: "RunContext[None]",
99-
*,
100-
request_context: "ModelRequestContext",
101-
response: "ModelResponse",
102-
) -> "ModelResponse":
103-
run_context_metadata = ctx.metadata
104-
if not isinstance(run_context_metadata, dict):
105-
return response
106-
107-
span = run_context_metadata["_sentry_span"]
108-
if span is None:
109-
return response
110-
111-
update_ai_client_span(span, response)
112-
span.__exit__(None, None, None)
113-
del run_context_metadata["_sentry_span"]
114-
115-
return response
116-
117-
@hooks.on.model_request_error # type: ignore
118-
async def on_error(
119-
ctx: "RunContext[None]",
120-
*,
121-
request_context: "ModelRequestContext",
122-
error: "Exception",
123-
) -> "ModelResponse":
124-
run_context_metadata = ctx.metadata
125-
if isinstance(run_context_metadata, dict):
126-
span = run_context_metadata.pop("_sentry_span", None)
127-
if span is not None:
128-
span.__exit__(type(error), error, error.__traceback__)
129-
raise error
130-
131-
original_init = Agent.__init__
132-
133-
@functools.wraps(original_init)
134-
def patched_init(
135-
self: "Agent[Any, Any]", *args: "Any", **kwargs: "Any"
136-
) -> None:
137-
caps = list(kwargs.get("capabilities") or [])
138-
caps.append(hooks)
139-
kwargs["capabilities"] = caps
140-
return original_init(self, *args, **kwargs)
141-
142-
Agent.__init__ = patched_init
167+
register_hooks(hooks)

tests/integrations/pydantic_ai/test_pydantic_ai.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from pydantic_ai.messages import BinaryContent, ImageUrl, UserPromptPart
1717
from pydantic_ai.usage import RequestUsage
1818
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
19+
from pydantic_ai.models.function import FunctionModel
1920

2021

2122
@pytest.fixture
@@ -94,6 +95,35 @@ async def test_agent_run_async(sentry_init, capture_events, get_test_agent):
9495
assert "gen_ai.usage.output_tokens" in chat_span["data"]
9596

9697

98+
@pytest.mark.asyncio
99+
async def test_agent_run_async_model_error(sentry_init, capture_events):
100+
sentry_init(
101+
integrations=[PydanticAIIntegration()],
102+
traces_sample_rate=1.0,
103+
)
104+
105+
events = capture_events()
106+
107+
def failing_model(messages, info):
108+
raise RuntimeError("model exploded")
109+
110+
agent = Agent(
111+
FunctionModel(failing_model),
112+
name="test_agent",
113+
)
114+
115+
with pytest.raises(RuntimeError, match="model exploded"):
116+
await agent.run("Test input")
117+
118+
(error, transaction) = events
119+
assert error["level"] == "error"
120+
121+
spans = transaction["spans"]
122+
assert len(spans) == 1
123+
124+
assert spans[0]["status"] == "internal_error"
125+
126+
97127
@pytest.mark.asyncio
98128
async def test_agent_run_async_usage_data(sentry_init, capture_events, get_test_agent):
99129
"""
@@ -174,6 +204,36 @@ def test_agent_run_sync(sentry_init, capture_events, get_test_agent):
174204
assert chat_span["data"]["gen_ai.response.streaming"] is False
175205

176206

207+
@pytest.mark.asyncio
208+
async def test_agent_run_sync_model_error(sentry_init, capture_events):
209+
sentry_init(
210+
integrations=[PydanticAIIntegration()],
211+
traces_sample_rate=1.0,
212+
)
213+
214+
events = capture_events()
215+
216+
def failing_model(messages, info):
217+
raise RuntimeError("model exploded")
218+
219+
agent = Agent(
220+
FunctionModel(failing_model),
221+
name="test_agent",
222+
)
223+
224+
with pytest.raises(RuntimeError, match="model exploded"):
225+
await agent.run("Test input")
226+
227+
print("events", len(events))
228+
(error, transaction) = events
229+
assert error["level"] == "error"
230+
231+
spans = transaction["spans"]
232+
assert len(spans) == 1
233+
234+
assert spans[0]["status"] == "internal_error"
235+
236+
177237
@pytest.mark.asyncio
178238
async def test_agent_run_stream(sentry_init, capture_events, get_test_agent):
179239
"""

0 commit comments

Comments
 (0)