From a51008416aef75f6446f6e258001a923f32a75d5 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:41:49 -0300 Subject: [PATCH 1/3] fix(errors): order and mark the stack of a never-raised exception An exception passed to capture_exception without being raised gets a stack built by the SDK. That stack came out innermost first, started with the SDK's own capture frames, and was sent with mechanism.synthetic false. Build it outermost first like a raised traceback, leave out the SDK frames, and mark it synthetic. --- .sampo/changesets/quiet-frost-tapio.md | 5 ++ posthog/exception_utils.py | 33 ++++++++--- posthog/test/test_exception_capture.py | 82 +++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 .sampo/changesets/quiet-frost-tapio.md diff --git a/.sampo/changesets/quiet-frost-tapio.md b/.sampo/changesets/quiet-frost-tapio.md new file mode 100644 index 000000000..660fd5ceb --- /dev/null +++ b/.sampo/changesets/quiet-frost-tapio.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +An exception passed to `capture_exception` without being raised now gets its synthesized stack in outermost-first order, without the SDK's own capture frames, and with `mechanism.synthetic` set to `true`. diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index 8608b96f0..8b4f8a03c 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -681,9 +681,12 @@ def single_exception_from_error_tuple( exception_value["mechanism"].setdefault("handled", True) exception_value["mechanism"].pop("source", None) - # Python capture inputs are runtime exceptions and this builder never - # replaces their stack with an SDK-generated current stack. - exception_value["mechanism"].setdefault("synthetic", False) + # The stack is synthetic only when the SDK built it, which happens for an + # exception that was passed to capture without ever being raised. + exception_value["mechanism"].setdefault( + "synthetic", + tb is not None and getattr(exc_value, _SYNTHESIZED_TRACEBACK_ATTR, None) is tb, + ) is_exception_group = BaseExceptionGroup is not None and isinstance( exc_value, BaseExceptionGroup @@ -1048,13 +1051,24 @@ def exc_info_from_error(error): return exc_info +_SYNTHESIZED_TRACEBACK_ATTR = "__posthog_synthesized_traceback" + + +def _is_sdk_frame(frame): + # type: (FrameType) -> bool + module = frame.f_globals.get("__name__") or "" + return module == "posthog" or module.startswith("posthog.") + + def construct_artificial_traceback(e): # type: (BaseException) -> None if getattr(e, "__traceback__", None) is not None: return + # Collect the current stack innermost first, leaving out the SDK's own + # capture frames so the stack ends where the application called the SDK. depth = 0 - frames = [] + frames = [] # type: List[FrameType] while True: try: frame = sys._getframe(depth) @@ -1062,15 +1076,20 @@ def construct_artificial_traceback(e): except ValueError: break + if not frames and _is_sdk_frame(frame): + continue frames.append(frame) - frames.reverse() - + # Link the traceback from the innermost frame outwards, so that following + # tb_next walks from the outermost frame to the innermost one, the same + # order the interpreter uses for a raised exception. tb = None for frame in frames: tb = types.TracebackType(tb, frame, frame.f_lasti, frame.f_lineno) - setattr(e, "__traceback__", tb) + if tb is not None: + setattr(e, "__traceback__", tb) + setattr(e, _SYNTHESIZED_TRACEBACK_ATTR, tb) def _module_in_list(name, items): diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index 011677779..6f0610c72 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -3,7 +3,7 @@ import threading from textwrap import dedent from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -526,3 +526,83 @@ def test_last_inspected_group_member_retains_its_cause_chain(): ] assert exceptions[-1]["mechanism"]["source"] == "cause" assert exceptions[-1]["mechanism"]["parent_id"] == 2 + + +_APP_MODULE_SOURCE = dedent( + """ + def entry_point(capture, error): + return handler(capture, error) + + def handler(capture, error): + return capture(error) + """ +) + + +def _call_from_app_module(capture, error): + # Run the capture call from frames that belong to an application module, + # not to the posthog package, the way a real caller would. + namespace = {"__name__": "app"} + exec(compile(_APP_MODULE_SOURCE, "app.py", "exec"), namespace) + return namespace["entry_point"](capture, error) + + +def _exceptions_for_never_raised(error): + from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple + + return exceptions_from_error_tuple( + _call_from_app_module(exc_info_from_error, error) + ) + + +def test_never_raised_exception_stack_is_outermost_first(): + exception = _exceptions_for_never_raised(ValueError("never raised"))[0] + + functions = [frame["function"] for frame in exception["stacktrace"]["frames"]] + assert functions.index("entry_point") < functions.index("handler") + assert functions[-1] == "handler" + + +def test_never_raised_exception_stack_omits_sdk_frames(): + exception = _exceptions_for_never_raised(ValueError("never raised"))[0] + + functions = [frame["function"] for frame in exception["stacktrace"]["frames"]] + assert "construct_artificial_traceback" not in functions + assert "exc_info_from_error" not in functions + + +def test_never_raised_exception_stack_is_marked_synthetic(): + exception = _exceptions_for_never_raised(ValueError("never raised"))[0] + + assert exception["mechanism"]["synthetic"] is True + + +def test_raised_exception_passed_by_object_is_not_synthetic(): + from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple + + try: + raise ValueError("raised") + except ValueError as error: + exceptions = exceptions_from_error_tuple(exc_info_from_error(error)) + + assert exceptions[0]["mechanism"]["synthetic"] is False + + +def test_client_capture_of_never_raised_exception_sends_synthetic_stack(): + from posthog.client import Client + + client = Client("phc_test", sync_mode=True) + try: + with patch.object(client, "capture", return_value="event-id") as capture: + _call_from_app_module( + lambda error: client.capture_exception(error, distinct_id="user"), + ValueError("never raised"), + ) + + exception = capture.call_args.kwargs["properties"]["$exception_list"][0] + functions = [frame["function"] for frame in exception["stacktrace"]["frames"]] + assert exception["mechanism"]["synthetic"] is True + assert functions.index("entry_point") < functions.index("handler") + assert "capture_exception" not in functions + finally: + client.shutdown() From f3f76fc4ec87f5ccdfa3347295b8c492451847c0 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:10:04 -0300 Subject: [PATCH 2/3] fix(errors): keep captured exceptions pickleable Store the id of the synthesized traceback instead of the traceback, which cannot be pickled. --- posthog/exception_utils.py | 9 ++++++--- posthog/test/test_exception_capture.py | 27 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index 8b4f8a03c..98cb75b73 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -685,7 +685,8 @@ def single_exception_from_error_tuple( # exception that was passed to capture without ever being raised. exception_value["mechanism"].setdefault( "synthetic", - tb is not None and getattr(exc_value, _SYNTHESIZED_TRACEBACK_ATTR, None) is tb, + tb is not None + and getattr(exc_value, _SYNTHESIZED_TRACEBACK_ID_ATTR, None) == id(tb), ) is_exception_group = BaseExceptionGroup is not None and isinstance( @@ -1051,7 +1052,7 @@ def exc_info_from_error(error): return exc_info -_SYNTHESIZED_TRACEBACK_ATTR = "__posthog_synthesized_traceback" +_SYNTHESIZED_TRACEBACK_ID_ATTR = "__posthog_synthesized_traceback_id" def _is_sdk_frame(frame): @@ -1089,7 +1090,9 @@ def construct_artificial_traceback(e): if tb is not None: setattr(e, "__traceback__", tb) - setattr(e, _SYNTHESIZED_TRACEBACK_ATTR, tb) + # Keep the id, not the traceback: a traceback in the instance dict would + # make the exception impossible to pickle. + setattr(e, _SYNTHESIZED_TRACEBACK_ID_ATTR, id(tb)) def _module_in_list(name, items): diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index 6f0610c72..57f85be83 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -1,3 +1,4 @@ +import pickle import subprocess import sys import threading @@ -606,3 +607,29 @@ def test_client_capture_of_never_raised_exception_sends_synthetic_stack(): assert "capture_exception" not in functions finally: client.shutdown() + + +def test_never_raised_exception_stays_pickleable_after_capture(): + from posthog.exception_utils import exc_info_from_error + + error = ValueError("never raised") + _call_from_app_module(exc_info_from_error, error) + + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, ValueError) + assert restored.args == ("never raised",) + + +def test_never_raised_exception_is_not_synthetic_once_raised(): + from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple + + error = ValueError("never raised") + _call_from_app_module(exc_info_from_error, error) + + try: + raise error + except ValueError as raised: + exceptions = exceptions_from_error_tuple(exc_info_from_error(raised)) + + assert exceptions[0]["mechanism"]["synthetic"] is False From 20de5fcd0dfd3743f3e62beb93a271ca8f73c662 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:15:50 -0300 Subject: [PATCH 3/3] fix(errors): hold the synthesized traceback and follow the mirror's package An id can be reused once the traceback is freed, so a pickled or copied exception could report a raised stack as synthetic. Keep the traceback in a small holder compared by identity, which reduces to None when the exception is pickled or deep copied. The SDK frame check only matched the posthog package, so the posthoganalytics mirror kept its capture frames. Take the package name from the module. --- posthog/exception_utils.py | 37 +++++++++++--- posthog/test/test_exception_capture.py | 70 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index 98cb75b73..a6f5883b2 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -685,8 +685,7 @@ def single_exception_from_error_tuple( # exception that was passed to capture without ever being raised. exception_value["mechanism"].setdefault( "synthetic", - tb is not None - and getattr(exc_value, _SYNTHESIZED_TRACEBACK_ID_ATTR, None) == id(tb), + tb is not None and _is_synthesized_traceback(exc_value, tb), ) is_exception_group = BaseExceptionGroup is not None and isinstance( @@ -1052,13 +1051,39 @@ def exc_info_from_error(error): return exc_info -_SYNTHESIZED_TRACEBACK_ID_ATTR = "__posthog_synthesized_traceback_id" +_SYNTHESIZED_TRACEBACK_ATTR = "__posthog_synthesized_traceback" + +# Taken from this module's name because the posthoganalytics mirror is the same +# code under another package name. +_SDK_PACKAGE = __name__.partition(".")[0] + + +class _SynthesizedTraceback: + """Holds the traceback the SDK built for an exception that was never raised.""" + + __slots__ = ("traceback",) + + def __init__(self, traceback): + # type: (TracebackType) -> None + self.traceback = traceback + + def __reduce__(self): + # A pickle or deep copy of the exception has no traceback to mark, and a + # traceback can't be pickled. Reducing to None also keeps this class out + # of the pickle, so it still loads where the SDK isn't installed. + return (type(None), ()) + + +def _is_synthesized_traceback(exc_value, tb): + # type: (Optional[BaseException], TracebackType) -> bool + marker = getattr(exc_value, _SYNTHESIZED_TRACEBACK_ATTR, None) + return marker is not None and marker.traceback is tb def _is_sdk_frame(frame): # type: (FrameType) -> bool module = frame.f_globals.get("__name__") or "" - return module == "posthog" or module.startswith("posthog.") + return module == _SDK_PACKAGE or module.startswith(_SDK_PACKAGE + ".") def construct_artificial_traceback(e): @@ -1090,9 +1115,7 @@ def construct_artificial_traceback(e): if tb is not None: setattr(e, "__traceback__", tb) - # Keep the id, not the traceback: a traceback in the instance dict would - # make the exception impossible to pickle. - setattr(e, _SYNTHESIZED_TRACEBACK_ID_ATTR, id(tb)) + setattr(e, _SYNTHESIZED_TRACEBACK_ATTR, _SynthesizedTraceback(tb)) def _module_in_list(name, items): diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index 57f85be83..a8ca09ff4 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -1,3 +1,5 @@ +import copy +import importlib.util import pickle import subprocess import sys @@ -633,3 +635,71 @@ def test_never_raised_exception_is_not_synthetic_once_raised(): exceptions = exceptions_from_error_tuple(exc_info_from_error(raised)) assert exceptions[0]["mechanism"]["synthetic"] is False + + +def _pickle_round_trip(error): + return pickle.loads(pickle.dumps(error)) + + +@pytest.mark.parametrize("duplicate", [_pickle_round_trip, copy.copy, copy.deepcopy]) +def test_copy_of_captured_exception_is_not_synthetic_once_raised(duplicate): + from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple + + # Capture inline, so no application frame keeps the exception alive and the + # synthesized traceback is freed as soon as the original goes away. Its + # address is then free for the traceback of the raise below. + for _ in range(50): + error = ValueError("never raised") + exc_info_from_error(error) + duplicated = duplicate(error) + del error + + try: + raise duplicated + except ValueError as raised: + exceptions = exceptions_from_error_tuple(exc_info_from_error(raised)) + + assert exceptions[0]["mechanism"]["synthetic"] is False + + +def test_captured_exception_can_be_unpickled_without_the_sdk(): + from posthog.exception_utils import exc_info_from_error + + error = ValueError("never raised") + _call_from_app_module(exc_info_from_error, error) + + result = subprocess.run( + [ + sys.executable, + "-I", + "-S", + "-c", + "import pickle, sys; print(pickle.loads(sys.stdin.buffer.read()))", + ], + input=pickle.dumps(error), + capture_output=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == b"never raised" + + +def test_never_raised_exception_stack_omits_sdk_frames_in_renamed_package(): + import posthog.exception_utils as exception_utils + + # The posthoganalytics mirror ships this module under another package name. + spec = importlib.util.spec_from_file_location( + "posthoganalytics.exception_utils", exception_utils.__file__ + ) + mirror = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {spec.name: mirror}): + spec.loader.exec_module(mirror) + exception = mirror.exceptions_from_error_tuple( + _call_from_app_module( + mirror.exc_info_from_error, ValueError("never raised") + ) + )[0] + + functions = [frame["function"] for frame in exception["stacktrace"]["frames"]] + assert functions[-1] == "handler" + assert exception["mechanism"]["synthetic"] is True