From 2cb0d646be6b3d9e22d03b8b495b89bfafad9799 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Fri, 3 Jul 2026 03:04:10 +0300 Subject: [PATCH 1/2] fix(errors): emit $exception_list in canonical order $exception_list[0] is now the caught/outermost exception, with each cause appended after its wrapper in unwrap order and the root cause last. Previously the list was reversed so the root cause came first, which is the opposite of every other SDK. This aligns posthog-python with the cross-SDK exception ordering spec (PostHog/sdk-specs#11). Frame order within each stacktrace is unchanged (already bottom-up). Adds regression tests for explicit cause, implicit context, and ExceptionGroup ordering. --- .../canonical-exception-list-order.md | 5 ++ posthog/exception_utils.py | 7 +- posthog/test/test_exception_capture.py | 82 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .sampo/changesets/canonical-exception-list-order.md diff --git a/.sampo/changesets/canonical-exception-list-order.md b/.sampo/changesets/canonical-exception-list-order.md new file mode 100644 index 00000000..56a86d2d --- /dev/null +++ b/.sampo/changesets/canonical-exception-list-order.md @@ -0,0 +1,5 @@ +--- +"posthog": minor +--- + +fix(errors): emit `$exception_list` in canonical order — index `0` is the caught/outermost exception, causes follow in unwrap order, and the root cause is last (previously the list was reversed with the root cause first). This aligns posthog-python with the cross-SDK exception ordering spec. Frame order within each stacktrace is unchanged. diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index ab7775a4..d2ab1652 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -716,8 +716,11 @@ def exceptions_from_error_tuple( single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism) ) - exceptions.reverse() - + # Canonical ordering: $exception_list[0] is the caught/outermost exception, + # with each cause appended after its wrapper in unwrap order and the root + # cause last. Both branches above already build the list in this order + # (walk_exception_chain yields caught-first; exceptions_from_error keeps the + # parent before its children), so we intentionally do not reverse it. return exceptions diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index bc632ba0..d9d969de 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -154,3 +154,85 @@ def test_excepthook(tmpdir): b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"' in output ) + + +class _RootError(Exception): + pass + + +class _WrapperError(Exception): + pass + + +class _LeafOne(Exception): + pass + + +class _LeafTwo(Exception): + pass + + +def test_exception_list_canonical_order_explicit_cause(): + # Canonical ordering: $exception_list[0] is the caught/outermost exception + # and the root cause is last. For `raise B from A`, B is caught and A is the + # root cause. + from posthog.exception_utils import exceptions_from_error_tuple + + try: + try: + raise _RootError("root") + except _RootError as root: + raise _WrapperError("wrapper") from root + except _WrapperError: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + types = [e["type"] for e in exceptions] + assert types == ["_WrapperError", "_RootError"] + assert exceptions[0]["value"] == "wrapper" + assert exceptions[-1]["value"] == "root" + + +def test_exception_list_canonical_order_implicit_context(): + # Implicit chaining (an exception raised while handling another) uses + # `__context__`. The caught exception is still first, root cause last. + from posthog.exception_utils import exceptions_from_error_tuple + + try: + try: + raise _RootError("root") + except _RootError: + raise _WrapperError("wrapper") + except _WrapperError: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + types = [e["type"] for e in exceptions] + assert types == ["_WrapperError", "_RootError"] + assert exceptions[0]["value"] == "wrapper" + assert exceptions[-1]["value"] == "root" + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="ExceptionGroup requires Python 3.11+", +) +def test_exception_list_canonical_order_exception_group(): + # For an ExceptionGroup the group is the caught/outermost exception and + # comes first, with its member exceptions following. + from posthog.exception_utils import exceptions_from_error_tuple + + try: + raise ExceptionGroup( # noqa: F821 -- builtin on 3.11+ + "group", [_LeafOne("one"), _LeafTwo("two")] + ) + except BaseException: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + types = [e["type"] for e in exceptions] + assert types[0] == "ExceptionGroup" + assert types[1:] == ["_LeafOne", "_LeafTwo"] From 96a008620d4c3046c99fb4d5a27dc4a76a91f4d2 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Fri, 3 Jul 2026 03:21:51 +0300 Subject: [PATCH 2/2] chore: use pypi/posthog changeset key --- .sampo/changesets/canonical-exception-list-order.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sampo/changesets/canonical-exception-list-order.md b/.sampo/changesets/canonical-exception-list-order.md index 56a86d2d..64179727 100644 --- a/.sampo/changesets/canonical-exception-list-order.md +++ b/.sampo/changesets/canonical-exception-list-order.md @@ -1,5 +1,5 @@ --- -"posthog": minor +pypi/posthog: minor --- fix(errors): emit `$exception_list` in canonical order — index `0` is the caught/outermost exception, causes follow in unwrap order, and the root cause is last (previously the list was reversed with the root cause first). This aligns posthog-python with the cross-SDK exception ordering spec. Frame order within each stacktrace is unchanged.