Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9f2c047
fix(openai-agents): Store invoke_agent span on agents.RunContextWrapper
alexander-alderman-webb Nov 27, 2025
4ca61e6
be defensive when accessing the agent span
alexander-alderman-webb Nov 27, 2025
2c0edd5
fix(openai-agents): Avoid double span exit on exception
alexander-alderman-webb Dec 1, 2025
cea080b
Merge branch 'master' into webb/store-span-on-openai-agents-context-w…
alexander-alderman-webb Dec 1, 2025
f9521de
Merge branch 'webb/store-span-on-openai-agents-context-wrapper' into …
alexander-alderman-webb Dec 1, 2025
5a70ca0
restore end existing span
alexander-alderman-webb Dec 1, 2025
baa1b59
mypy
alexander-alderman-webb Dec 1, 2025
e6e40b1
access correct attribute
alexander-alderman-webb Dec 1, 2025
cb23da0
deduplicate
alexander-alderman-webb Dec 1, 2025
bc982ed
delattr on exit
alexander-alderman-webb Dec 1, 2025
a8fb881
Merge branch 'webb/store-span-on-openai-agents-context-wrapper' into …
alexander-alderman-webb Dec 1, 2025
557fc90
delattr on exit
alexander-alderman-webb Dec 1, 2025
dd3063b
call _end_invoke_agent_span instead of manually closing span
alexander-alderman-webb Dec 1, 2025
e5d5c52
add except block
alexander-alderman-webb Dec 1, 2025
e738f3d
move end_invoke_agent_span
alexander-alderman-webb Dec 1, 2025
64c2cfa
forgot __init__.py
alexander-alderman-webb Dec 1, 2025
cea38a2
mypy
alexander-alderman-webb Dec 1, 2025
90f5dba
capture_exception first
alexander-alderman-webb Dec 1, 2025
59732ed
do not capture exception twice
alexander-alderman-webb Dec 1, 2025
e9e9e3a
capture all exceptions again
alexander-alderman-webb Dec 1, 2025
4580edc
type annotation
alexander-alderman-webb Dec 1, 2025
0477a4b
mypy
alexander-alderman-webb Dec 1, 2025
ef3ddc6
remove unreachable assertion
alexander-alderman-webb Dec 1, 2025
470bbbb
merge master
alexander-alderman-webb Dec 1, 2025
ea91e54
merge master
alexander-alderman-webb Dec 2, 2025
b3e5d01
simplify exception cases
alexander-alderman-webb Dec 2, 2025
5d9e0d0
more capture_exception to outer layer
alexander-alderman-webb Dec 2, 2025
c7a8a44
remove _SingleTurnException
alexander-alderman-webb Dec 2, 2025
65a230f
deduplicate end agent invocation functions
alexander-alderman-webb Dec 2, 2025
ba09279
remove unused imports
alexander-alderman-webb Dec 2, 2025
1acb4d0
.
alexander-alderman-webb Dec 2, 2025
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
37 changes: 32 additions & 5 deletions sentry_sdk/integrations/openai_agents/patches/agent_run.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
from functools import wraps

from sentry_sdk.integrations import DidNotEnable
from ..spans import invoke_agent_span, update_invoke_agent_span, handoff_span
from sentry_sdk.tracing_utils import set_span_errored
from ..spans import (
invoke_agent_span,
update_invoke_agent_span,
end_invoke_agent_span,
handoff_span,
)
from ..utils import _capture_exception, _record_exception_on_span, _SingleTurnException

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any, Optional

from sentry_sdk.tracing import Span

try:
import agents
from agents.exceptions import AgentsException
except ImportError:
raise DidNotEnable("OpenAI Agents not installed")

Expand All @@ -27,13 +37,15 @@ def _patch_agent_run():
original_execute_final_output = agents._run_impl.RunImpl.execute_final_output

def _start_invoke_agent_span(context_wrapper, agent, kwargs):
# type: (agents.RunContextWrapper, agents.Agent, dict[str, Any]) -> None
# type: (agents.RunContextWrapper, agents.Agent, dict[str, Any]) -> Span
"""Start an agent invocation span"""
# Store the agent on the context wrapper so we can access it later
context_wrapper._sentry_current_agent = agent
span = invoke_agent_span(context_wrapper, agent, kwargs)
context_wrapper._sentry_agent_span = span

return span

def _end_invoke_agent_span(context_wrapper, agent, output=None):
# type: (agents.RunContextWrapper, agents.Agent, Optional[Any]) -> None
"""End the agent invocation span"""
Expand Down Expand Up @@ -65,18 +77,33 @@ async def patched_run_single_turn(cls, *args, **kwargs):
context_wrapper = kwargs.get("context_wrapper")
should_run_agent_start_hooks = kwargs.get("should_run_agent_start_hooks")

span = getattr(context_wrapper, "_sentry_agent_span", None)
# Start agent span when agent starts (but only once per agent)
if should_run_agent_start_hooks and agent and context_wrapper:
# End any existing span for a different agent
if _has_active_agent_span(context_wrapper):
current_agent = _get_current_agent(context_wrapper)
if current_agent and current_agent != agent:
_end_invoke_agent_span(context_wrapper, current_agent)
end_invoke_agent_span(context_wrapper, current_agent)

_start_invoke_agent_span(context_wrapper, agent, kwargs)
span = _start_invoke_agent_span(context_wrapper, agent, kwargs)

# Call original method with all the correct parameters
result = await original_run_single_turn(*args, **kwargs)
try:
result = await original_run_single_turn(*args, **kwargs)
except AgentsException:
# AgentsException is caught on AgentRunner.run().
# Exceptions are captured and agent invocation spans are explicitly finished
# as long as only AgentRunner.run() invokes AgentRunner._run_single_turn().
raise
except Exception as exc:
_capture_exception(exc)

if span is not None and span.timestamp is None:
_record_exception_on_span(span, exc)
end_invoke_agent_span(context_wrapper, agent)

raise _SingleTurnException(exc)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I created _SingleTurnException to avoid calling capture_exception() twice.

Otherwise, exceptions raised in _run_single_turn() that are not of type AgentsException would be captured in both run() and _run_single_turn().


return result

Expand Down
12 changes: 2 additions & 10 deletions sentry_sdk/integrations/openai_agents/patches/error_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sentry_sdk
from sentry_sdk.consts import SPANSTATUS
from sentry_sdk.tracing_utils import set_span_errored
from ..utils import _record_exception_on_span

from typing import TYPE_CHECKING

Expand Down Expand Up @@ -58,16 +59,7 @@ def sentry_attach_error_to_current_span(error, *args, **kwargs):
# Set the current Sentry span to errored
current_span = sentry_sdk.get_current_span()
if current_span is not None:
set_span_errored(current_span)
current_span.set_data("span.status", "error")

# Optionally capture the error details if we have them
if hasattr(error, "__class__"):
current_span.set_data("error.type", error.__class__.__name__)
if hasattr(error, "__str__"):
error_message = str(error)
if error_message:
current_span.set_data("error.message", error_message)
_record_exception_on_span(current_span, error)

# Call the original function
return original_attach_error(error, *args, **kwargs)
Expand Down
44 changes: 34 additions & 10 deletions sentry_sdk/integrations/openai_agents/patches/runner.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from functools import wraps

import sentry_sdk
from sentry_sdk.integrations import DidNotEnable

from ..spans import agent_workflow_span
from ..utils import _capture_exception
from ..spans import agent_workflow_span, end_invoke_agent_span
from ..utils import _capture_exception, _record_exception_on_span, _SingleTurnException

try:
from agents.exceptions import AgentsException
except ImportError:
raise DidNotEnable("OpenAI Agents not installed")

from typing import TYPE_CHECKING

Expand All @@ -28,18 +34,36 @@ async def wrapper(*args, **kwargs):
with sentry_sdk.isolation_scope():
agent = args[0]
with agent_workflow_span(agent):
result = None
try:
result = await original_func(*args, **kwargs)
return result
except Exception as exc:
run_result = await original_func(*args, **kwargs)
except AgentsException as exc:
_capture_exception(exc)

# It could be that there is a "invoke agent" span still open
current_span = sentry_sdk.get_current_span()
if current_span is not None and current_span.timestamp is None:
current_span.__exit__(None, None, None)
context_wrapper = getattr(exc.run_data, "context_wrapper", None)
if context_wrapper is not None:
invoke_agent_span = getattr(
context_wrapper, "_sentry_agent_span", None
)

if (
invoke_agent_span is not None
and invoke_agent_span.timestamp is None
):
_record_exception_on_span(invoke_agent_span, exc)
end_invoke_agent_span(context_wrapper, agent)

raise exc from None
except _SingleTurnException as exc:
# Handled in _run_single_turn() patch.
raise exc.original from None
except Exception as exc:
# Invoke agent span is not finished in this case.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I could add a get_current_span() again in this case and we would never lose spans.

You also can't guarantee that the SDK does not crash if you try to exit a span you obtain with get_current_span(), so I decided the just drop the span for now.

Much of AgentRunner.run() that is not spent in _run_single_turn() is before the agent invocation span is created anyway. The only cases in which we lose a span is that any of the post-turn steps like output guardrails raise an exception that is not an AgentsException.

# This is much less likely to occur than other cases because
# AgentRunner.run() is "just" a while loop around _run_single_turn.
_capture_exception(exc)
Copy link
Member

Choose a reason for hiding this comment

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

it's hard for me to follow the flow, but my recommendation would be this:

  • we should not call capture_exception here at all, just have a finally block that takes care of the span management
  • the exception should be captured only in the runner.py patch
  • the library internally already decides what to re-raise and what to swallow and we don't need to care about those, only what the library finally emits

Copy link
Member

Choose a reason for hiding this comment

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

sorry I meant that we should only have this one and not the one in agent_run

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason there are special cases for exceptions, and for handling exceptions at different levels is that

  • openai-agents only attaches the context wrapper when AgentsException is raised; and
  • we always have a handle on the context_wrapper in agent_run.py, and only sometimes in runner.py.

Regarding having a finally block, I did not go with that approach for different reasons in agent_run.py and runner.py.

In agent_run.py, one agent invocation can span multiple calls to the function we patch, so it's intentional to only finish the span in the except block. The broad Exception is caught here so we can finish the span even when an exception which does not subclass AgentsException is raised. In runner.py we do not have a handle on the span if it is not an AgentsException.

In runner.py, we do not have a finally block because you access the context wrapper from different places depending on the code path. In the non-exception case, the context wrapper is on the return value of the function, and in the exception case, the context wrapper is attached to the exception object.

Copy link
Member

Choose a reason for hiding this comment

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

so it's intentional to only finish the span in the except block.

in that case keeping the span management in the except block is fine.

But I still think the capture_exception should only be there on the outermost layer. We should not be coupling exception handling and span management this tightly.

Regarding handling missing state on our end, we should simply write logic with presence checks so we don't rely on the actual underlying implementation of the lib so much. Currently, much of the design is very brittle to internals changing.

Copy link
Member

Choose a reason for hiding this comment

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

the span context manager __exit__ already responds to exceptions, but instead whoever wrote this decided to pass (None, None, None) here which makes everything extra complicated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've moved capture_exception to the outer layer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also realized we don't need this _SingleTurnException stuff so that's removed now.
Hopefully that makes it slightly less brittle to internals changing.

raise exc from None

end_invoke_agent_span(run_result.context_wrapper, agent)

This comment was marked as outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

duplicate of #5174 (comment)

return run_result

return wrapper
6 changes: 5 additions & 1 deletion sentry_sdk/integrations/openai_agents/spans/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@
from .ai_client import ai_client_span, update_ai_client_span # noqa: F401
from .execute_tool import execute_tool_span, update_execute_tool_span # noqa: F401
from .handoff import handoff_span # noqa: F401
from .invoke_agent import invoke_agent_span, update_invoke_agent_span # noqa: F401
from .invoke_agent import (
invoke_agent_span,
update_invoke_agent_span,
end_invoke_agent_span,
) # noqa: F401
12 changes: 11 additions & 1 deletion sentry_sdk/integrations/openai_agents/spans/invoke_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

if TYPE_CHECKING:
import agents
from typing import Any
from typing import Any, Optional


def invoke_agent_span(context, agent, kwargs):
Expand Down Expand Up @@ -91,3 +91,13 @@ def update_invoke_agent_span(context, agent, output):

span.__exit__(None, None, None)
delattr(context, "_sentry_agent_span")


def end_invoke_agent_span(context_wrapper, agent, output=None):
# type: (agents.RunContextWrapper, agents.Agent, Optional[Any]) -> None
"""End the agent invocation span"""
# Clear the stored agent
if hasattr(context_wrapper, "_sentry_current_agent"):
delattr(context_wrapper, "_sentry_current_agent")

update_invoke_agent_span(context_wrapper, agent, output)
22 changes: 22 additions & 0 deletions sentry_sdk/integrations/openai_agents/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@
from typing import Any
from agents import Usage

from sentry_sdk.tracing import Span

try:
import agents

except ImportError:
raise DidNotEnable("OpenAI Agents not installed")


class _SingleTurnException(Exception):
def __init__(self, original):
# type: (Exception) -> None
self.original = original


def _capture_exception(exc):
# type: (Any) -> None
set_span_errored()
Expand All @@ -37,6 +45,20 @@ def _capture_exception(exc):
sentry_sdk.capture_event(event, hint=hint)


def _record_exception_on_span(span, error):
# type: (Span, Exception) -> Any
set_span_errored(span)
span.set_data("span.status", "error")

# Optionally capture the error details if we have them
if hasattr(error, "__class__"):
span.set_data("error.type", error.__class__.__name__)
if hasattr(error, "__str__"):
error_message = str(error)
if error_message:
span.set_data("error.message", error_message)


def _set_agent_data(span, agent):
# type: (sentry_sdk.tracing.Span, agents.Agent) -> None
span.set_data(
Expand Down
Loading