Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions sentry_sdk/integrations/pydantic_ai/patches/agent_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async def __aenter__(self) -> "Any":

# 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)
push_agent(self.agent)

# Enter the original context manager
result = await self.original_ctx_manager.__aenter__()
Expand Down Expand Up @@ -120,7 +120,7 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
) as span:
# 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)
push_agent(self)

try:
result = await original_func(self, *args, **kwargs)
Expand Down
22 changes: 22 additions & 0 deletions sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from contextlib import asynccontextmanager
from functools import wraps

from sentry_sdk.consts import SPANDATA
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.traces import StreamedSpan

from ..spans import (
ai_client_span,
Expand Down Expand Up @@ -59,9 +61,19 @@

@wraps(original_model_request_run)
async def wrapped_model_request_run(self: "Any", ctx: "Any") -> "Any":
did_stream = getattr(self, "_did_stream", None)
cached_result = getattr(self, "_result", None)
if did_stream or cached_result is not None:
return await original_model_request_run(self, ctx)

messages, model, model_settings = _extract_span_data(self, ctx)

with ai_client_span(messages, None, model, model_settings) as span:
if isinstance(span, StreamedSpan):
span.set_attribute(SPANDATA.GEN_AI_RESPONSE_STREAMING, False)
else:
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, False)

result = await original_model_request_run(self, ctx)

# Extract response from result if available
Expand All @@ -85,10 +97,20 @@
@asynccontextmanager
@wraps(original_stream_method)
async def wrapped_model_request_stream(self: "Any", ctx: "Any") -> "Any":
did_stream = getattr(self, "_did_stream", None)
if did_stream:
async with original_stream_method(self, ctx) as stream:

Check failure on line 102 in sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py

View check run for this annotation

@sentry/warden / warden: code-review

Async stream wrapper falls through and yields twice when did_stream is true

When `did_stream` is true, the early guard yields the stream but then falls through to a second `yield`, which `@asynccontextmanager` will reject with `RuntimeError: generator didn't stop`.
Comment on lines +100 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async stream wrapper falls through and yields twice when did_stream is true

When did_stream is true, the early guard yields the stream but then falls through to a second yield, which @asynccontextmanager will reject with RuntimeError: generator didn't stop.

Evidence
  • wrapped_model_request_stream is decorated with @asynccontextmanager.
  • When did_stream is truthy, the wrapper yields from original_stream_method at line 102 but does not return or use an else guard.
  • Execution falls through to a second async with original_stream_method(self, ctx) at line 114 and yield stream at line 115.
  • @asynccontextmanager expects exactly one yield; a second yield causes RuntimeError: generator didn't stop.

Identified by Warden · code-review · EAF-FN4

yield stream

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:
if isinstance(span, StreamedSpan):
span.set_attribute(SPANDATA.GEN_AI_RESPONSE_STREAMING, False)
else:

Check warning on line 111 in sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py

View check run for this annotation

@sentry/warden / warden: code-review

Stream wrapper sets GEN_AI_RESPONSE_STREAMING to False instead of True

The `wrapped_model_request_stream` wrapper sets `GEN_AI_RESPONSE_STREAMING` to `False`, but since this is the streaming method it should be `True`.
Comment on lines +109 to +111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream wrapper sets GEN_AI_RESPONSE_STREAMING to False instead of True

The wrapped_model_request_stream wrapper sets GEN_AI_RESPONSE_STREAMING to False, but since this is the streaming method it should be True.

Evidence
  • The PR title says the goal is to determine if the response is streamed based on the method.
  • wrapped_model_request_run (non-streaming) correctly sets the value to False at lines 72-74.
  • wrapped_model_request_stream (streaming) sets the same value to False at lines 109-111, but streaming methods in other integrations (openai_agents, anthropic, google_genai) set it to True.

Identified by Warden · code-review · 6UA-65U

span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, False)

Check warning on line 112 in sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Stream wrapper incorrectly marks response as non-streaming

`wrapped_model_request_stream` is the streaming path for `ModelRequestNode.stream`, but it sets `GEN_AI_RESPONSE_STREAMING` to `False` instead of `True`.

# Call the original stream method
async with original_stream_method(self, ctx) as stream:
yield stream
Expand Down
4 changes: 0 additions & 4 deletions sentry_sdk/integrations/pydantic_ai/spans/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
_set_model_data,
_should_send_prompts,
get_current_agent,
get_is_streaming,
)
from .utils import (
_serialize_binary_content_item,
Expand Down Expand Up @@ -314,7 +313,6 @@ def ai_client_span(
"sentry.op": OP.GEN_AI_CHAT,
"sentry.origin": SPAN_ORIGIN,
SPANDATA.GEN_AI_OPERATION_NAME: "chat",
SPANDATA.GEN_AI_RESPONSE_STREAMING: get_is_streaming(),
},
)
else:
Expand All @@ -325,8 +323,6 @@ def ai_client_span(
)

span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat")
# Set streaming flag from contextvar
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, get_is_streaming())

_set_agent_data(span, agent)
_set_model_data(span, model, model_settings)
Expand Down
16 changes: 4 additions & 12 deletions sentry_sdk/integrations/pydantic_ai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
)


def push_agent(agent: "Any", is_streaming: bool = False) -> None:
"""Push an agent context onto the stack along with its streaming flag."""
def push_agent(agent: "Any") -> None:
"""Push an agent context onto the stack."""
stack = _agent_context_stack.get().copy()
stack.append({"agent": agent, "is_streaming": is_streaming})
stack.append(agent)
_agent_context_stack.set(stack)


Expand All @@ -37,18 +37,10 @@ def get_current_agent() -> "Any":
"""Get the current agent from the contextvar stack."""
stack = _agent_context_stack.get()
if stack:
return stack[-1]["agent"]
return stack[-1]
return None


def get_is_streaming() -> bool:
"""Get the streaming flag from the contextvar stack."""
stack = _agent_context_stack.get()
if stack:
return stack[-1].get("is_streaming", False)
return False


def _should_send_prompts() -> bool:
"""
Check if prompts should be sent to Sentry.
Expand Down
Loading