From 71d2cfb8699bbaff8c99ec2bd3b87b3be06a7940 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 14 Jan 2026 13:29:25 +0100 Subject: [PATCH 1/2] fix: Stop suppressing exception chains in AI integrations --- scripts/find_raise_from_none.py | 65 +++++++++++++++++++ sentry_sdk/ai/monitoring.py | 35 +++++----- sentry_sdk/integrations/anthropic.py | 14 ++-- sentry_sdk/integrations/cohere.py | 17 +++-- sentry_sdk/integrations/huggingface_hub.py | 10 ++- sentry_sdk/integrations/openai.py | 38 +++++++---- .../openai_agents/patches/runner.py | 43 ++++++------ .../pydantic_ai/patches/agent_run.py | 14 ++-- .../integrations/pydantic_ai/patches/tools.py | 33 +++++----- tox.ini | 5 +- 10 files changed, 194 insertions(+), 80 deletions(-) create mode 100644 scripts/find_raise_from_none.py diff --git a/scripts/find_raise_from_none.py b/scripts/find_raise_from_none.py new file mode 100644 index 0000000000..63b2b84333 --- /dev/null +++ b/scripts/find_raise_from_none.py @@ -0,0 +1,65 @@ +import ast +import pathlib +from collections import defaultdict + + +class RaiseFromNoneVisitor(ast.NodeVisitor): + line_numbers = defaultdict(list) + + def __init__(self, filename): + self.filename = filename + + def visit_Raise(self, node: ast.Raise): + if node.cause is not None: + if isinstance(node.cause, ast.Constant) and node.cause.value is None: + RaiseFromNoneVisitor.line_numbers[self.filename].append(node.lineno) + self.generic_visit(node) + + +def scan_file(module_path: pathlib.Path): + source = pathlib.Path(module_path).read_text(encoding="utf-8") + tree = ast.parse(source, filename=module_path) + + RaiseFromNoneVisitor(module_path).visit(tree) + + +def walk_package_modules(): + for p in pathlib.Path("sentry_sdk").rglob("*.py"): + yield p + + +def format_detected_raises(line_numbers) -> str: + lines = [] + for filepath, line_numbers_in_file in line_numbers.items(): + lines_string = ", ".join(f"line {ln}" for ln in sorted(line_numbers_in_file)) + lines.append( + f"{filepath}: {len(line_numbers_in_file)} occurrence(s) at {lines_string}" + ) + return "\n".join(lines) + + +def main(): + for module_path in walk_package_modules(): + scan_file(module_path) + + # TODO: Investigate why we suppress exception chains here. + ignored_raises = { + pathlib.Path("sentry_sdk/integrations/asgi.py"): 2, + pathlib.Path("sentry_sdk/integrations/asyncio.py"): 1, + } + + raise_from_none_count = { + file: len(occurences) + for file, occurences in RaiseFromNoneVisitor.line_numbers.items() + } + if raise_from_none_count != ignored_raises: + exc = Exception("Detected unexpected raise ... from None.") + exc.add_note( + "Raise ... from None suppresses chained exceptions, removing valuable context." + ) + exc.add_note(format_detected_raises(RaiseFromNoneVisitor.line_numbers)) + raise exc + + +if __name__ == "__main__": + main() diff --git a/sentry_sdk/ai/monitoring.py b/sentry_sdk/ai/monitoring.py index e7e00ad462..5655712d53 100644 --- a/sentry_sdk/ai/monitoring.py +++ b/sentry_sdk/ai/monitoring.py @@ -1,11 +1,12 @@ import inspect +import sys from functools import wraps from sentry_sdk.consts import SPANDATA import sentry_sdk.utils from sentry_sdk import start_span from sentry_sdk.tracing import Span -from sentry_sdk.utils import ContextVar +from sentry_sdk.utils import ContextVar, reraise, capture_internal_exceptions from typing import TYPE_CHECKING @@ -44,13 +45,15 @@ def sync_wrapped(*args: "Any", **kwargs: "Any") -> "Any": try: res = f(*args, **kwargs) except Exception as e: - event, hint = sentry_sdk.utils.event_from_exception( - e, - client_options=sentry_sdk.get_client().options, - mechanism={"type": "ai_monitoring", "handled": False}, - ) - sentry_sdk.capture_event(event, hint=hint) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + event, hint = sentry_sdk.utils.event_from_exception( + e, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "ai_monitoring", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + reraise(*exc_info) finally: _ai_pipeline_name.set(None) return res @@ -72,13 +75,15 @@ async def async_wrapped(*args: "Any", **kwargs: "Any") -> "Any": try: res = await f(*args, **kwargs) except Exception as e: - event, hint = sentry_sdk.utils.event_from_exception( - e, - client_options=sentry_sdk.get_client().options, - mechanism={"type": "ai_monitoring", "handled": False}, - ) - sentry_sdk.capture_event(event, hint=hint) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + event, hint = sentry_sdk.utils.event_from_exception( + e, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "ai_monitoring", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + reraise(*exc_info) finally: _ai_pipeline_name.set(None) return res diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 5257e3bf60..55a9f14ee0 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -1,3 +1,4 @@ +import sys from collections.abc import Iterable from functools import wraps from typing import TYPE_CHECKING @@ -20,6 +21,7 @@ event_from_exception, package_version, safe_serialize, + reraise, ) try: @@ -386,8 +388,10 @@ def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = f(*args, **kwargs) except Exception as exc: - _capture_exception(exc) - raise exc from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) + reraise(*exc_info) return gen.send(result) except StopIteration as e: @@ -422,8 +426,10 @@ async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = await f(*args, **kwargs) except Exception as exc: - _capture_exception(exc) - raise exc from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) + reraise(*exc_info) return gen.send(result) except StopIteration as e: diff --git a/sentry_sdk/integrations/cohere.py b/sentry_sdk/integrations/cohere.py index bac2ce5655..f45a02f2b5 100644 --- a/sentry_sdk/integrations/cohere.py +++ b/sentry_sdk/integrations/cohere.py @@ -1,3 +1,4 @@ +import sys from functools import wraps from sentry_sdk import consts @@ -16,7 +17,7 @@ import sentry_sdk from sentry_sdk.scope import should_send_default_pii from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.utils import capture_internal_exceptions, event_from_exception +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception, reraise try: from cohere.client import Client @@ -151,9 +152,11 @@ def new_chat(*args: "Any", **kwargs: "Any") -> "Any": try: res = f(*args, **kwargs) except Exception as e: - _capture_exception(e) - span.__exit__(None, None, None) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + span.__exit__(None, None, None) + reraise(*exc_info) with capture_internal_exceptions(): if should_send_default_pii() and integration.include_prompts: @@ -247,8 +250,10 @@ def new_embed(*args: "Any", **kwargs: "Any") -> "Any": try: res = f(*args, **kwargs) except Exception as e: - _capture_exception(e) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + reraise(*exc_info) if ( hasattr(res, "meta") and hasattr(res.meta, "billed_units") diff --git a/sentry_sdk/integrations/huggingface_hub.py b/sentry_sdk/integrations/huggingface_hub.py index 39a667dde9..8509cadefa 100644 --- a/sentry_sdk/integrations/huggingface_hub.py +++ b/sentry_sdk/integrations/huggingface_hub.py @@ -1,3 +1,4 @@ +import sys import inspect from functools import wraps @@ -11,6 +12,7 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, + reraise, ) from typing import TYPE_CHECKING @@ -126,9 +128,11 @@ def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any": try: res = f(*args, **kwargs) except Exception as e: - _capture_exception(e) - span.__exit__(None, None, None) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + span.__exit__(None, None, None) + reraise(*exc_info) # Output attributes finish_reason = None diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index a2c7cc8d1d..66dc4a1c48 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -1,3 +1,4 @@ +import sys from functools import wraps import sentry_sdk @@ -16,6 +17,7 @@ capture_internal_exceptions, event_from_exception, safe_serialize, + reraise, ) from typing import TYPE_CHECKING @@ -483,8 +485,10 @@ def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = f(*args, **kwargs) except Exception as e: - _capture_exception(e) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + reraise(*exc_info) return gen.send(result) except StopIteration as e: @@ -515,8 +519,10 @@ async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = await f(*args, **kwargs) except Exception as e: - _capture_exception(e) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + reraise(*exc_info) return gen.send(result) except StopIteration as e: @@ -569,8 +575,10 @@ def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = f(*args, **kwargs) except Exception as e: - _capture_exception(e, manual_span_cleanup=False) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e, manual_span_cleanup=False) + reraise(*exc_info) return gen.send(result) except StopIteration as e: @@ -600,8 +608,10 @@ async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = await f(*args, **kwargs) except Exception as e: - _capture_exception(e, manual_span_cleanup=False) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e, manual_span_cleanup=False) + reraise(*exc_info) return gen.send(result) except StopIteration as e: @@ -655,8 +665,10 @@ def _execute_sync(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = f(*args, **kwargs) except Exception as e: - _capture_exception(e) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + reraise(*exc_info) return gen.send(result) except StopIteration as e: @@ -686,8 +698,10 @@ async def _execute_async(f: "Any", *args: "Any", **kwargs: "Any") -> "Any": try: result = await f(*args, **kwargs) except Exception as e: - _capture_exception(e) - raise e from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + reraise(*exc_info) return gen.send(result) except StopIteration as e: diff --git a/sentry_sdk/integrations/openai_agents/patches/runner.py b/sentry_sdk/integrations/openai_agents/patches/runner.py index 1d3bbc894b..4b6171103a 100644 --- a/sentry_sdk/integrations/openai_agents/patches/runner.py +++ b/sentry_sdk/integrations/openai_agents/patches/runner.py @@ -1,7 +1,9 @@ +import sys from functools import wraps import sentry_sdk from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.utils import capture_internal_exceptions, reraise from ..spans import agent_workflow_span, end_invoke_agent_span from ..utils import _capture_exception, _record_exception_on_span @@ -37,28 +39,31 @@ async def wrapper(*args: "Any", **kwargs: "Any") -> "Any": try: run_result = await original_func(*args, **kwargs) except AgentsException as exc: - _capture_exception(exc) + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) - 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 - ) + 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 + 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) + reraise(*exc_info) except Exception as exc: - # Invoke agent span is not finished in this case. - # 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) - raise exc from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + # Invoke agent span is not finished in this case. + # 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) + reraise(*exc_info) end_invoke_agent_span(run_result.context_wrapper, agent) return run_result diff --git a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py index d158d892d5..eaa4385834 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/agent_run.py @@ -1,7 +1,9 @@ +import sys from functools import wraps import sentry_sdk from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.utils import capture_internal_exceptions, reraise from ..spans import invoke_agent_span, update_invoke_agent_span from ..utils import _capture_exception, pop_agent, push_agent @@ -121,8 +123,10 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": return result except Exception as exc: - _capture_exception(exc) - raise exc from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) + reraise(*exc_info) finally: # Pop agent from contextvar stack pop_agent() @@ -177,8 +181,10 @@ async def wrapper(self: "Any", *args: "Any", **kwargs: "Any") -> "Any": async for event in original_func(self, *args, **kwargs): yield event except Exception as exc: - _capture_exception(exc) - raise exc from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(exc) + reraise(*exc_info) return wrapper diff --git a/sentry_sdk/integrations/pydantic_ai/patches/tools.py b/sentry_sdk/integrations/pydantic_ai/patches/tools.py index b826a543fc..394d44f0f3 100644 --- a/sentry_sdk/integrations/pydantic_ai/patches/tools.py +++ b/sentry_sdk/integrations/pydantic_ai/patches/tools.py @@ -1,7 +1,9 @@ +import sys from functools import wraps from sentry_sdk.integrations import DidNotEnable import sentry_sdk +from sentry_sdk.utils import capture_internal_exceptions, reraise from ..spans import execute_tool_span, update_execute_tool_span from ..utils import _capture_exception, get_current_agent @@ -81,21 +83,22 @@ async def wrapped_call_tool( update_execute_tool_span(span, result) return result except ToolRetryError as exc: - # Avoid circular import due to multi-file integration structure - from sentry_sdk.integrations.pydantic_ai import ( - PydanticAIIntegration, - ) - - integration = sentry_sdk.get_client().get_integration( - PydanticAIIntegration - ) - if ( - integration is None - or not integration.handled_tool_call_exceptions - ): - raise exc from None - _capture_exception(exc, handled=True) - raise exc from None + exc_info = sys.exc_info() + with capture_internal_exceptions(): + # Avoid circular import due to multi-file integration structure + from sentry_sdk.integrations.pydantic_ai import ( + PydanticAIIntegration, + ) + + integration = sentry_sdk.get_client().get_integration( + PydanticAIIntegration + ) + if ( + integration is not None + and integration.handled_tool_call_exceptions + ): + _capture_exception(exc, handled=True) + reraise(*exc_info) # No span context - just call original return await original_call_tool( diff --git a/tox.ini b/tox.ini index 67955f18bf..52719e3df4 100644 --- a/tox.ini +++ b/tox.ini @@ -925,5 +925,6 @@ commands = [testenv:linters] commands = ruff check tests sentry_sdk - ruff format --check tests sentry_sdk - mypy sentry_sdk + ; ruff format --check tests sentry_sdk + ; mypy sentry_sdk + python scripts/find_raise_from_none.py From a332c308329e1e813fb301023572bca645ac687f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 14 Jan 2026 13:31:34 +0100 Subject: [PATCH 2/2] add tox.ini change to template --- scripts/populate_tox/tox.jinja | 1 + tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index e01832abcb..7e49c2156d 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -230,3 +230,4 @@ commands = ruff check tests sentry_sdk ruff format --check tests sentry_sdk mypy sentry_sdk + python scripts/find_raise_from_none.py diff --git a/tox.ini b/tox.ini index 52719e3df4..9d40dc9f44 100644 --- a/tox.ini +++ b/tox.ini @@ -925,6 +925,6 @@ commands = [testenv:linters] commands = ruff check tests sentry_sdk - ; ruff format --check tests sentry_sdk - ; mypy sentry_sdk + ruff format --check tests sentry_sdk + mypy sentry_sdk python scripts/find_raise_from_none.py