Skip to content

Commit df06590

Browse files
committed
feat(integrations): implement context management for invoke_agent spans
- 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.
1 parent 2f966c6 commit df06590

File tree

4 files changed

+96
-42
lines changed

4 files changed

+96
-42
lines changed

sentry_sdk/integrations/pydantic_ai/patches/agent_run.py

Lines changed: 11 additions & 14 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, push_invoke_agent_span, pop_invoke_agent_span
77

88
from typing import TYPE_CHECKING
99
from pydantic_ai.agent import Agent # type: ignore
@@ -41,17 +41,15 @@ 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(
5146
self.user_prompt, self.agent, self.model, self.model_settings
5247
)
5348
self._span.__enter__()
5449

50+
# Push span and agent to contextvar stack
51+
push_invoke_agent_span(self._span, self.agent)
52+
5553
# Enter the original context manager
5654
result = await self.original_ctx_manager.__aenter__()
5755
self._result = result
@@ -71,7 +69,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
7169
if self._span is not None:
7270
update_invoke_agent_span(self._span, output)
7371
finally:
74-
sentry_sdk.get_current_scope().remove_context("pydantic_ai_agent")
72+
# Pop span from contextvar stack
73+
pop_invoke_agent_span()
74+
7575
# Clean up invoke span
7676
if self._span:
7777
self._span.__exit__(exc_type, exc_val, exc_tb)
@@ -97,19 +97,15 @@ async def wrapper(self, *args, **kwargs):
9797
# Isolate each workflow so that when agents are run in asyncio tasks they
9898
# don't touch each other's scopes
9999
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-
106100
# Extract parameters for the span
107101
user_prompt = kwargs.get("user_prompt") or (args[0] if args else None)
108102
model = kwargs.get("model")
109103
model_settings = kwargs.get("model_settings")
110104

111105
# Create invoke_agent span
112106
with invoke_agent_span(user_prompt, self, model, model_settings) as span:
107+
# Push span and agent to contextvar stack
108+
push_invoke_agent_span(span, self)
113109
try:
114110
result = await original_func(self, *args, **kwargs)
115111

@@ -122,7 +118,8 @@ async def wrapper(self, *args, **kwargs):
122118
_capture_exception(exc)
123119
raise exc from None
124120
finally:
125-
sentry_sdk.get_current_scope().remove_context("pydantic_ai_agent")
121+
# Pop span from contextvar stack
122+
pop_invoke_agent_span()
126123

127124
return wrapper
128125

sentry_sdk/integrations/pydantic_ai/patches/tools.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
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+
get_current_invoke_agent_span,
12+
)
913

1014
from typing import TYPE_CHECKING
1115

@@ -49,20 +53,21 @@ async def wrapped_call_tool(self, call, allow_partial, wrap_validation_errors):
4953
if tool and HAS_MCP and isinstance(tool.toolset, MCPServer):
5054
tool_type = "mcp"
5155

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")
56+
# Get agent and invoke_agent span from contextvar
57+
agent = get_current_agent()
58+
invoke_span = get_current_invoke_agent_span()
5959

60+
if invoke_span and tool:
6061
try:
6162
args_dict = call.args_as_dict()
6263
except Exception:
6364
args_dict = call.args if isinstance(call.args, dict) else {}
6465

65-
with execute_tool_span(name, args_dict, agent, tool_type=tool_type) as span:
66+
# Create execute_tool span as a child of invoke_agent span
67+
# Passing parent_span ensures parallel tools are siblings under the same parent
68+
with execute_tool_span(
69+
name, args_dict, agent, tool_type=tool_type, parent_span=invoke_span
70+
) as span:
6671
try:
6772
result = await original_call_tool(
6873
self, call, allow_partial, wrap_validation_errors

sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,38 @@
88
from typing import TYPE_CHECKING
99

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

1313

14-
def execute_tool_span(tool_name, tool_args, agent, tool_type="function"):
15-
# type: (str, Any, Any, str) -> sentry_sdk.tracing.Span
14+
def execute_tool_span(
15+
tool_name, tool_args, agent, tool_type="function", parent_span=None
16+
):
17+
# type: (str, Any, Any, str, Optional[sentry_sdk.tracing.Span]) -> sentry_sdk.tracing.Span
1618
"""Create a span for tool execution.
1719
1820
Args:
1921
tool_name: The name of the tool being executed
2022
tool_args: The arguments passed to the tool
2123
agent: The agent executing the tool
2224
tool_type: The type of tool ("function" for regular tools, "mcp" for MCP services)
25+
parent_span: Optional parent span to create this as a child of. If provided,
26+
uses parent_span.start_child() to ensure parallel tools are siblings.
2327
"""
24-
span = sentry_sdk.start_span(
25-
op=OP.GEN_AI_EXECUTE_TOOL,
26-
name=f"execute_tool {tool_name}",
27-
origin=SPAN_ORIGIN,
28-
)
28+
if parent_span:
29+
# Create as child of the specified parent span
30+
# This ensures parallel tool calls are siblings under the same parent
31+
span = parent_span.start_child(
32+
op=OP.GEN_AI_EXECUTE_TOOL,
33+
name=f"execute_tool {tool_name}",
34+
origin=SPAN_ORIGIN,
35+
)
36+
else:
37+
# Create as child of current span
38+
span = sentry_sdk.start_span(
39+
op=OP.GEN_AI_EXECUTE_TOOL,
40+
name=f"execute_tool {tool_name}",
41+
origin=SPAN_ORIGIN,
42+
)
2943

3044
span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "execute_tool")
3145
span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, tool_type)

sentry_sdk/integrations/pydantic_ai/utils.py

Lines changed: 49 additions & 11 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,48 @@
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 invoke_agent span in a contextvar for re-entrant safety
15+
# Using a list as a stack to support nested agent calls
16+
_invoke_agent_span_stack = ContextVar("pydantic_ai_invoke_agent_span_stack", default=[])
17+
# type: ContextVar[list[dict[str, Any]]]
18+
19+
20+
def push_invoke_agent_span(span, agent):
21+
# type: (sentry_sdk.tracing.Span, Any) -> None
22+
"""Push an invoke_agent span onto the stack along with its agent."""
23+
stack = _invoke_agent_span_stack.get().copy()
24+
stack.append({"span": span, "agent": agent})
25+
_invoke_agent_span_stack.set(stack)
26+
27+
28+
def pop_invoke_agent_span():
29+
# type: () -> None
30+
"""Pop an invoke_agent span from the stack."""
31+
stack = _invoke_agent_span_stack.get().copy()
32+
if stack:
33+
stack.pop()
34+
_invoke_agent_span_stack.set(stack)
35+
36+
37+
def get_current_invoke_agent_span():
38+
# type: () -> Optional[sentry_sdk.tracing.Span]
39+
"""Get the current invoke_agent span (top of stack)."""
40+
stack = _invoke_agent_span_stack.get()
41+
if stack:
42+
return stack[-1]["span"]
43+
return None
44+
45+
46+
def get_current_agent():
47+
# type: () -> Any
48+
"""Get the current agent from the contextvar stack."""
49+
stack = _invoke_agent_span_stack.get()
50+
if stack:
51+
return stack[-1]["agent"]
52+
return None
1153

1254

1355
def _should_send_prompts():
@@ -37,16 +79,13 @@ def _set_agent_data(span, agent):
3779
3880
Args:
3981
span: The span to set data on
40-
agent: Agent object (can be None, will try to get from Sentry scope if not provided)
82+
agent: Agent object (can be None, will try to get from contextvar if not provided)
4183
"""
42-
# Extract agent name from agent object or Sentry scope
84+
# Extract agent name from agent object or contextvar
4385
agent_obj = agent
4486
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")
87+
# Try to get from contextvar
88+
agent_obj = get_current_agent()
5089

5190
if agent_obj and hasattr(agent_obj, "name") and agent_obj.name:
5291
span.set_data(SPANDATA.GEN_AI_AGENT_NAME, agent_obj.name)
@@ -87,9 +126,8 @@ def _set_model_data(span, model, model_settings):
87126
model: Model object (can be None, will try to get from agent if not provided)
88127
model_settings: Model settings (can be None, will try to get from agent if not provided)
89128
"""
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")
129+
# Try to get agent from contextvar if we need it
130+
agent_obj = get_current_agent()
93131

94132
# Extract model information
95133
model_obj = model

0 commit comments

Comments
 (0)