Skip to content

Commit 0f9f51c

Browse files
szokeasaurusrexsentrivanaantonpirker
authored
Adapt AI Agent Monitoring for potel (#4551)
Port the `openai-agents` integration to `potel-base`. --------- Co-authored-by: Ivana Kellyer <[email protected]> Co-authored-by: Anton Pirker <[email protected]>
1 parent 23a49e8 commit 0f9f51c

File tree

22 files changed

+623
-63
lines changed

22 files changed

+623
-63
lines changed

.github/workflows/test-integrations-ai.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ jobs:
5959
run: |
6060
set -x # print commands that are executed
6161
./scripts/runtox.sh "py${{ matrix.python-version }}-openai-latest"
62+
- name: Test openai_agents latest
63+
run: |
64+
set -x # print commands that are executed
65+
./scripts/runtox.sh "py${{ matrix.python-version }}-openai_agents-latest"
6266
- name: Test huggingface_hub latest
6367
run: |
6468
set -x # print commands that are executed
@@ -121,6 +125,10 @@ jobs:
121125
run: |
122126
set -x # print commands that are executed
123127
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai"
128+
- name: Test openai_agents pinned
129+
run: |
130+
set -x # print commands that are executed
131+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai_agents"
124132
- name: Test huggingface_hub pinned
125133
run: |
126134
set -x # print commands that are executed

scripts/populate_tox/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@
152152
"loguru": {
153153
"package": "loguru",
154154
},
155+
"openai_agents": {
156+
"package": "openai-agents",
157+
"deps": {
158+
"*": ["pytest-asyncio"],
159+
},
160+
},
155161
"openfeature": {
156162
"package": "openfeature-sdk",
157163
},

scripts/populate_tox/tox.jinja

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ setenv =
372372
litestar: TESTPATH=tests/integrations/litestar
373373
loguru: TESTPATH=tests/integrations/loguru
374374
openai: TESTPATH=tests/integrations/openai
375+
openai_agents: TESTPATH=tests/integrations/openai_agents
375376
openfeature: TESTPATH=tests/integrations/openfeature
376377
pure_eval: TESTPATH=tests/integrations/pure_eval
377378
pymongo: TESTPATH=tests/integrations/pymongo

scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"cohere",
6464
"langchain",
6565
"openai",
66+
"openai_agents",
6667
"huggingface_hub",
6768
],
6869
"Cloud": [

sentry_sdk/integrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def iter_default_integrations(
147147
"launchdarkly": (9, 8, 0),
148148
"loguru": (0, 7, 0),
149149
"openai": (1, 0, 0),
150+
"openai_agents": (0, 0, 19),
150151
"openfeature": (0, 7, 1),
151152
"quart": (0, 16, 0),
152153
"ray": (2, 7, 0),
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from sentry_sdk.integrations import DidNotEnable, Integration
2+
3+
from .patches import (
4+
_create_get_model_wrapper,
5+
_create_get_all_tools_wrapper,
6+
_create_run_wrapper,
7+
_patch_agent_run,
8+
)
9+
10+
try:
11+
import agents
12+
13+
except ImportError:
14+
raise DidNotEnable("OpenAI Agents not installed")
15+
16+
17+
def _patch_runner() -> None:
18+
# Create the root span for one full agent run (including eventual handoffs)
19+
# Note agents.run.DEFAULT_AGENT_RUNNER.run_sync is a wrapper around
20+
# agents.run.DEFAULT_AGENT_RUNNER.run. It does not need to be wrapped separately.
21+
# TODO-anton: Also patch streaming runner: agents.Runner.run_streamed
22+
agents.run.DEFAULT_AGENT_RUNNER.run = _create_run_wrapper(
23+
agents.run.DEFAULT_AGENT_RUNNER.run
24+
)
25+
26+
# Creating the actual spans for each agent run.
27+
_patch_agent_run()
28+
29+
30+
def _patch_model() -> None:
31+
agents.run.AgentRunner._get_model = classmethod(
32+
_create_get_model_wrapper(agents.run.AgentRunner._get_model),
33+
)
34+
35+
36+
def _patch_tools() -> None:
37+
agents.run.AgentRunner._get_all_tools = classmethod(
38+
_create_get_all_tools_wrapper(agents.run.AgentRunner._get_all_tools),
39+
)
40+
41+
42+
class OpenAIAgentsIntegration(Integration):
43+
identifier = "openai_agents"
44+
45+
@staticmethod
46+
def setup_once() -> None:
47+
_patch_tools()
48+
_patch_model()
49+
_patch_runner()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SPAN_ORIGIN = "auto.ai.openai_agents"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .models import _create_get_model_wrapper # noqa: F401
2+
from .tools import _create_get_all_tools_wrapper # noqa: F401
3+
from .runner import _create_run_wrapper # noqa: F401
4+
from .agent_run import _patch_agent_run # noqa: F401
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
from functools import wraps
4+
5+
from sentry_sdk.integrations import DidNotEnable
6+
7+
from ..spans import invoke_agent_span, update_invoke_agent_span, handoff_span
8+
9+
from typing import TYPE_CHECKING
10+
11+
if TYPE_CHECKING:
12+
from typing import Any, Optional
13+
14+
15+
try:
16+
import agents
17+
except ImportError:
18+
raise DidNotEnable("OpenAI Agents not installed")
19+
20+
21+
def _patch_agent_run() -> None:
22+
"""
23+
Patches AgentRunner methods to create agent invocation spans.
24+
This directly patches the execution flow to track when agents start and stop.
25+
"""
26+
27+
# Store original methods
28+
original_run_single_turn = agents.run.AgentRunner._run_single_turn
29+
original_execute_handoffs = agents._run_impl.RunImpl.execute_handoffs
30+
original_execute_final_output = agents._run_impl.RunImpl.execute_final_output
31+
32+
def _start_invoke_agent_span(
33+
context_wrapper: agents.RunContextWrapper, agent: agents.Agent
34+
) -> None:
35+
"""Start an agent invocation span"""
36+
# Store the agent on the context wrapper so we can access it later
37+
context_wrapper._sentry_current_agent = agent
38+
invoke_agent_span(context_wrapper, agent)
39+
40+
def _end_invoke_agent_span(
41+
context_wrapper: agents.RunContextWrapper,
42+
agent: agents.Agent,
43+
output: Optional[Any] = None,
44+
) -> None:
45+
"""End the agent invocation span"""
46+
# Clear the stored agent
47+
if hasattr(context_wrapper, "_sentry_current_agent"):
48+
delattr(context_wrapper, "_sentry_current_agent")
49+
50+
update_invoke_agent_span(context_wrapper, agent, output)
51+
52+
def _has_active_agent_span(context_wrapper: agents.RunContextWrapper) -> bool:
53+
"""Check if there's an active agent span for this context"""
54+
return getattr(context_wrapper, "_sentry_current_agent", None) is not None
55+
56+
def _get_current_agent(
57+
context_wrapper: agents.RunContextWrapper,
58+
) -> Optional[agents.Agent]:
59+
"""Get the current agent from context wrapper"""
60+
return getattr(context_wrapper, "_sentry_current_agent", None)
61+
62+
@wraps(
63+
original_run_single_turn.__func__
64+
if hasattr(original_run_single_turn, "__func__")
65+
else original_run_single_turn
66+
)
67+
async def patched_run_single_turn(
68+
cls: agents.Runner, *args: Any, **kwargs: Any
69+
) -> Any:
70+
"""Patched _run_single_turn that creates agent invocation spans"""
71+
agent = kwargs.get("agent")
72+
context_wrapper = kwargs.get("context_wrapper")
73+
should_run_agent_start_hooks = kwargs.get("should_run_agent_start_hooks")
74+
75+
# Start agent span when agent starts (but only once per agent)
76+
if should_run_agent_start_hooks and agent and context_wrapper:
77+
# End any existing span for a different agent
78+
if _has_active_agent_span(context_wrapper):
79+
current_agent = _get_current_agent(context_wrapper)
80+
if current_agent and current_agent != agent:
81+
_end_invoke_agent_span(context_wrapper, current_agent)
82+
83+
_start_invoke_agent_span(context_wrapper, agent)
84+
85+
# Call original method with all the correct parameters
86+
try:
87+
result = await original_run_single_turn(*args, **kwargs)
88+
finally:
89+
if agent and context_wrapper and _has_active_agent_span(context_wrapper):
90+
_end_invoke_agent_span(context_wrapper, agent)
91+
92+
return result
93+
94+
@wraps(
95+
original_execute_handoffs.__func__
96+
if hasattr(original_execute_handoffs, "__func__")
97+
else original_execute_handoffs
98+
)
99+
async def patched_execute_handoffs(
100+
cls: agents.Runner, *args: Any, **kwargs: Any
101+
) -> Any:
102+
"""Patched execute_handoffs that creates handoff spans and ends agent span for handoffs"""
103+
context_wrapper = kwargs.get("context_wrapper")
104+
run_handoffs = kwargs.get("run_handoffs")
105+
agent = kwargs.get("agent")
106+
107+
# Create Sentry handoff span for the first handoff (agents library only processes the first one)
108+
if run_handoffs:
109+
first_handoff = run_handoffs[0]
110+
handoff_agent_name = first_handoff.handoff.agent_name
111+
handoff_span(context_wrapper, agent, handoff_agent_name)
112+
113+
# Call original method with all parameters
114+
try:
115+
result = await original_execute_handoffs(*args, **kwargs)
116+
117+
finally:
118+
# End span for current agent after handoff processing is complete
119+
if agent and context_wrapper and _has_active_agent_span(context_wrapper):
120+
_end_invoke_agent_span(context_wrapper, agent)
121+
122+
return result
123+
124+
@wraps(
125+
original_execute_final_output.__func__
126+
if hasattr(original_execute_final_output, "__func__")
127+
else original_execute_final_output
128+
)
129+
async def patched_execute_final_output(
130+
cls: agents.Runner, *args: Any, **kwargs: Any
131+
) -> Any:
132+
"""Patched execute_final_output that ends agent span for final outputs"""
133+
agent = kwargs.get("agent")
134+
context_wrapper = kwargs.get("context_wrapper")
135+
final_output = kwargs.get("final_output")
136+
137+
# Call original method with all parameters
138+
try:
139+
result = await original_execute_final_output(*args, **kwargs)
140+
finally:
141+
# End span for current agent after final output processing is complete
142+
if agent and context_wrapper and _has_active_agent_span(context_wrapper):
143+
_end_invoke_agent_span(context_wrapper, agent, final_output)
144+
145+
return result
146+
147+
# Apply patches
148+
agents.run.AgentRunner._run_single_turn = classmethod(patched_run_single_turn)
149+
agents._run_impl.RunImpl.execute_handoffs = classmethod(patched_execute_handoffs)
150+
agents._run_impl.RunImpl.execute_final_output = classmethod(
151+
patched_execute_final_output
152+
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
from functools import wraps
4+
5+
from sentry_sdk.integrations import DidNotEnable
6+
7+
from ..spans import ai_client_span, update_ai_client_span
8+
9+
from typing import TYPE_CHECKING
10+
11+
if TYPE_CHECKING:
12+
from typing import Any, Callable
13+
14+
15+
try:
16+
import agents
17+
except ImportError:
18+
raise DidNotEnable("OpenAI Agents not installed")
19+
20+
21+
def _create_get_model_wrapper(
22+
original_get_model: Callable[..., Any],
23+
) -> Callable[..., Any]:
24+
"""
25+
Wraps the agents.Runner._get_model method to wrap the get_response method of the model to create a AI client span.
26+
"""
27+
28+
@wraps(
29+
original_get_model.__func__
30+
if hasattr(original_get_model, "__func__")
31+
else original_get_model
32+
)
33+
def wrapped_get_model(
34+
cls: agents.Runner, agent: agents.Agent, run_config: agents.RunConfig
35+
) -> agents.Model:
36+
model = original_get_model(agent, run_config)
37+
original_get_response = model.get_response
38+
39+
@wraps(original_get_response)
40+
async def wrapped_get_response(*args: Any, **kwargs: Any) -> Any:
41+
with ai_client_span(agent, kwargs) as span:
42+
result = await original_get_response(*args, **kwargs)
43+
44+
update_ai_client_span(span, agent, kwargs, result)
45+
46+
return result
47+
48+
model.get_response = wrapped_get_response
49+
50+
return model
51+
52+
return wrapped_get_model

0 commit comments

Comments
 (0)