Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .sampo/changesets/canonical-exception-list-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
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.
7 changes: 5 additions & 2 deletions posthog/exception_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
82 changes: 82 additions & 0 deletions posthog/test/test_exception_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading