Skip to content

Commit 3101fe0

Browse files
feat(integrations): implement context management for invoke_agent spans (#5089)
- Introduced context variables to manage nested invoke_agent spans safely. - Added functions to push and pop spans from the context stack. - Updated existing code to utilize context variables instead of Sentry scope for agent management. - Enhanced execute_tool_span to support parent-child relationships between spans. This change improves the handling of agent spans during nested calls, ensuring better traceability and isolation of spans in asynchronous contexts. ### Description <!-- What changed and why? --> #### Issues * Closes https://linear.app/getsentry/issue/TET-1388/pydantic-ai-replace-current-scope-context-with-contextvar * Closes https://linear.app/getsentry/issue/TET-1373/pydantic-ai-wrong-hierarchy-of-execute-tool-spans #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr)
1 parent 9e14920 commit 3101fe0

File tree

6 files changed

+113
-60
lines changed

6 files changed

+113
-60
lines changed

sentry_sdk/integrations/pydantic_ai/patches/agent_run.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sentry_sdk
44

55
from ..spans import invoke_agent_span, update_invoke_agent_span
6-
from ..utils import _capture_exception
6+
from ..utils import _capture_exception, pop_agent, push_agent
77

88
from typing import TYPE_CHECKING
99
from pydantic_ai.agent import Agent # type: ignore
@@ -41,17 +41,20 @@ async def __aenter__(self):
4141
self._isolation_scope = sentry_sdk.isolation_scope()
4242
self._isolation_scope.__enter__()
4343

44-
# Store agent reference and streaming flag
45-
sentry_sdk.get_current_scope().set_context(
46-
"pydantic_ai_agent", {"_agent": self.agent, "_streaming": self.is_streaming}
47-
)
48-
4944
# Create invoke_agent span (will be closed in __aexit__)
5045
self._span = invoke_agent_span(
51-
self.user_prompt, self.agent, self.model, self.model_settings
46+
self.user_prompt,
47+
self.agent,
48+
self.model,
49+
self.model_settings,
50+
self.is_streaming,
5251
)
5352
self._span.__enter__()
5453

54+
# Push agent to contextvar stack after span is successfully created and entered
55+
# This ensures proper pairing with pop_agent() in __aexit__ even if exceptions occur
56+
push_agent(self.agent, self.is_streaming)
57+
5558
# Enter the original context manager
5659
result = await self.original_ctx_manager.__aenter__()
5760
self._result = result
@@ -71,7 +74,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
7174
if self._span is not None:
7275
update_invoke_agent_span(self._span, output)
7376
finally:
74-
sentry_sdk.get_current_scope().remove_context("pydantic_ai_agent")
77+
# Pop agent from contextvar stack
78+
pop_agent()
79+
7580
# Clean up invoke span
7681
if self._span:
7782
self._span.__exit__(exc_type, exc_val, exc_tb)
@@ -97,19 +102,19 @@ async def wrapper(self, *args, **kwargs):
97102
# Isolate each workflow so that when agents are run in asyncio tasks they
98103
# don't touch each other's scopes
99104
with sentry_sdk.isolation_scope():
100-
# Store agent reference and streaming flag in Sentry scope for access in nested spans
101-
# We store the full agent to allow access to tools and system prompts
102-
sentry_sdk.get_current_scope().set_context(
103-
"pydantic_ai_agent", {"_agent": self, "_streaming": is_streaming}
104-
)
105-
106105
# Extract parameters for the span
107106
user_prompt = kwargs.get("user_prompt") or (args[0] if args else None)
108107
model = kwargs.get("model")
109108
model_settings = kwargs.get("model_settings")
110109

111110
# Create invoke_agent span
112-
with invoke_agent_span(user_prompt, self, model, model_settings) as span:
111+
with invoke_agent_span(
112+
user_prompt, self, model, model_settings, is_streaming
113+
) as span:
114+
# Push agent to contextvar stack after span is successfully created and entered
115+
# This ensures proper pairing with pop_agent() in finally even if exceptions occur
116+
push_agent(self, is_streaming)
117+
113118
try:
114119
result = await original_func(self, *args, **kwargs)
115120

@@ -122,7 +127,8 @@ async def wrapper(self, *args, **kwargs):
122127
_capture_exception(exc)
123128
raise exc from None
124129
finally:
125-
sentry_sdk.get_current_scope().remove_context("pydantic_ai_agent")
130+
# Pop agent from contextvar stack
131+
pop_agent()
126132

127133
return wrapper
128134

sentry_sdk/integrations/pydantic_ai/patches/tools.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import sentry_sdk
66

77
from ..spans import execute_tool_span, update_execute_tool_span
8-
from ..utils import _capture_exception
8+
from ..utils import (
9+
_capture_exception,
10+
get_current_agent,
11+
)
912

1013
from typing import TYPE_CHECKING
1114

@@ -49,29 +52,43 @@ async def wrapped_call_tool(self, call, *args, **kwargs):
4952
if tool and HAS_MCP and isinstance(tool.toolset, MCPServer):
5053
tool_type = "mcp"
5154

52-
# Get agent from Sentry scope
53-
current_span = sentry_sdk.get_current_span()
54-
if current_span and tool:
55-
agent_data = (
56-
sentry_sdk.get_current_scope()._contexts.get("pydantic_ai_agent") or {}
57-
)
58-
agent = agent_data.get("_agent")
55+
# Get agent from contextvar
56+
agent = get_current_agent()
5957

58+
if agent and tool:
6059
try:
6160
args_dict = call.args_as_dict()
6261
except Exception:
6362
args_dict = call.args if isinstance(call.args, dict) else {}
6463

65-
with execute_tool_span(name, args_dict, agent, tool_type=tool_type) as span:
66-
try:
67-
result = await original_call_tool(self, call, *args, **kwargs)
68-
update_execute_tool_span(span, result)
69-
return result
70-
except Exception as exc:
71-
_capture_exception(exc)
72-
raise exc from None
64+
# Create execute_tool span
65+
# Nesting is handled by isolation_scope() to ensure proper parent-child relationships
66+
with sentry_sdk.isolation_scope():
67+
with execute_tool_span(
68+
name,
69+
args_dict,
70+
agent,
71+
tool_type=tool_type,
72+
) as span:
73+
try:
74+
result = await original_call_tool(
75+
self,
76+
call,
77+
*args,
78+
**kwargs,
79+
)
80+
update_execute_tool_span(span, result)
81+
return result
82+
except Exception as exc:
83+
_capture_exception(exc)
84+
raise exc from None
7385

7486
# No span context - just call original
75-
return await original_call_tool(self, call, *args, **kwargs)
87+
return await original_call_tool(
88+
self,
89+
call,
90+
*args,
91+
**kwargs,
92+
)
7693

7794
ToolManager._call_tool = wrapped_call_tool

sentry_sdk/integrations/pydantic_ai/spans/ai_client.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
_set_model_data,
1111
_should_send_prompts,
1212
_get_model_name,
13+
get_current_agent,
14+
get_is_streaming,
1315
)
1416

1517
from typing import TYPE_CHECKING
@@ -216,20 +218,11 @@ def ai_client_span(messages, agent, model, model_settings):
216218
_set_agent_data(span, agent)
217219
_set_model_data(span, model, model_settings)
218220

219-
# Set streaming flag
220-
agent_data = sentry_sdk.get_current_scope()._contexts.get("pydantic_ai_agent") or {}
221-
is_streaming = agent_data.get("_streaming", False)
222-
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, is_streaming)
221+
# Set streaming flag from contextvar
222+
span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, get_is_streaming())
223223

224224
# Add available tools if agent is available
225-
agent_obj = agent
226-
if not agent_obj:
227-
# Try to get from Sentry scope
228-
agent_data = (
229-
sentry_sdk.get_current_scope()._contexts.get("pydantic_ai_agent") or {}
230-
)
231-
agent_obj = agent_data.get("_agent")
232-
225+
agent_obj = agent or get_current_agent()
233226
_set_available_tools(span, agent_obj)
234227

235228
# Set input messages (full conversation history)

sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import TYPE_CHECKING
99

1010
if TYPE_CHECKING:
11-
from typing import Any
11+
from typing import Any, Optional
1212

1313

1414
def execute_tool_span(tool_name, tool_args, agent, tool_type="function"):

sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
from typing import Any
1717

1818

19-
def invoke_agent_span(user_prompt, agent, model, model_settings):
20-
# type: (Any, Any, Any, Any) -> sentry_sdk.tracing.Span
19+
def invoke_agent_span(user_prompt, agent, model, model_settings, is_streaming=False):
20+
# type: (Any, Any, Any, Any, bool) -> sentry_sdk.tracing.Span
2121
"""Create a span for invoking the agent."""
2222
# Determine agent name for span
2323
name = "agent"

sentry_sdk/integrations/pydantic_ai/utils.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sentry_sdk
2+
from contextvars import ContextVar
23
from sentry_sdk.consts import SPANDATA
34
from sentry_sdk.scope import should_send_default_pii
45
from sentry_sdk.tracing_utils import set_span_errored
@@ -7,7 +8,47 @@
78
from typing import TYPE_CHECKING
89

910
if TYPE_CHECKING:
10-
from typing import Any
11+
from typing import Any, Optional
12+
13+
14+
# Store the current agent context in a contextvar for re-entrant safety
15+
# Using a list as a stack to support nested agent calls
16+
_agent_context_stack = ContextVar("pydantic_ai_agent_context_stack", default=[]) # type: ContextVar[list[dict[str, Any]]]
17+
18+
19+
def push_agent(agent, is_streaming=False):
20+
# type: (Any, bool) -> None
21+
"""Push an agent context onto the stack along with its streaming flag."""
22+
stack = _agent_context_stack.get().copy()
23+
stack.append({"agent": agent, "is_streaming": is_streaming})
24+
_agent_context_stack.set(stack)
25+
26+
27+
def pop_agent():
28+
# type: () -> None
29+
"""Pop an agent context from the stack."""
30+
stack = _agent_context_stack.get().copy()
31+
if stack:
32+
stack.pop()
33+
_agent_context_stack.set(stack)
34+
35+
36+
def get_current_agent():
37+
# type: () -> Any
38+
"""Get the current agent from the contextvar stack."""
39+
stack = _agent_context_stack.get()
40+
if stack:
41+
return stack[-1]["agent"]
42+
return None
43+
44+
45+
def get_is_streaming():
46+
# type: () -> bool
47+
"""Get the streaming flag from the contextvar stack."""
48+
stack = _agent_context_stack.get()
49+
if stack:
50+
return stack[-1].get("is_streaming", False)
51+
return False
1152

1253

1354
def _should_send_prompts():
@@ -37,23 +78,20 @@ def _set_agent_data(span, agent):
3778
3879
Args:
3980
span: The span to set data on
40-
agent: Agent object (can be None, will try to get from Sentry scope if not provided)
81+
agent: Agent object (can be None, will try to get from contextvar if not provided)
4182
"""
42-
# Extract agent name from agent object or Sentry scope
83+
# Extract agent name from agent object or contextvar
4384
agent_obj = agent
4485
if not agent_obj:
45-
# Try to get from Sentry scope
46-
agent_data = (
47-
sentry_sdk.get_current_scope()._contexts.get("pydantic_ai_agent") or {}
48-
)
49-
agent_obj = agent_data.get("_agent")
86+
# Try to get from contextvar
87+
agent_obj = get_current_agent()
5088

5189
if agent_obj and hasattr(agent_obj, "name") and agent_obj.name:
5290
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_obj.name)
5391

5492

5593
def _get_model_name(model_obj):
56-
# type: (Any) -> str | None
94+
# type: (Any) -> Optional[str]
5795
"""Extract model name from a model object.
5896
5997
Args:
@@ -87,9 +125,8 @@ def _set_model_data(span, model, model_settings):
87125
model: Model object (can be None, will try to get from agent if not provided)
88126
model_settings: Model settings (can be None, will try to get from agent if not provided)
89127
"""
90-
# Try to get agent from Sentry scope if we need it
91-
agent_data = sentry_sdk.get_current_scope()._contexts.get("pydantic_ai_agent") or {}
92-
agent_obj = agent_data.get("_agent")
128+
# Try to get agent from contextvar if we need it
129+
agent_obj = get_current_agent()
93130

94131
# Extract model information
95132
model_obj = model

0 commit comments

Comments
 (0)