From 89d556af72000a4dd37b6cdb6b754f56cbf218e0 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Wed, 26 Aug 2026 10:21:17 +0200 Subject: [PATCH 1/3] feat(error-tracking): standardize exception metadata --- .../canonical-exception-metadata.md | 5 + posthog/client.py | 43 +++- posthog/exception_capture.py | 35 +++- posthog/exception_utils.py | 195 ++++++++++++------ posthog/integrations/celery.py | 12 +- posthog/integrations/django.py | 12 +- .../integrations/test_celery_integration.py | 27 ++- posthog/test/integrations/test_middleware.py | 18 +- posthog/test/test_exception_capture.py | 34 ++- 9 files changed, 291 insertions(+), 90 deletions(-) create mode 100644 .sampo/changesets/canonical-exception-metadata.md diff --git a/.sampo/changesets/canonical-exception-metadata.md b/.sampo/changesets/canonical-exception-metadata.md new file mode 100644 index 000000000..edd37fa13 --- /dev/null +++ b/.sampo/changesets/canonical-exception-metadata.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Standardize exception capture metadata, including severity, capture source, mechanism semantics, deterministic cause linkage, and reserved property ownership. diff --git a/posthog/client.py b/posthog/client.py index dcdae7cd1..1f66e0e5b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -57,6 +57,7 @@ _get_current_otel_span_properties, handle_in_app, mark_exception_as_captured, + _normalize_exception_level, try_attach_code_variables_to_frames, ) from posthog.feature_flag_evaluations import ( @@ -2056,7 +2057,16 @@ def capture_exception( return None # Format stack trace for cymbal - all_exceptions_with_trace = exceptions_from_error_tuple(exc_info) + capture_metadata_input = dict(kwargs).get("_capture_metadata") + capture_metadata = ( + capture_metadata_input + if isinstance(capture_metadata_input, dict) + else {} + ) + mechanism = capture_metadata.get("mechanism") + all_exceptions_with_trace = exceptions_from_error_tuple( + exc_info, mechanism=mechanism if isinstance(mechanism, dict) else None + ) # Add in-app property to frames in the exceptions event = handle_in_app( @@ -2070,11 +2080,38 @@ def capture_exception( ) all_exceptions_with_trace_and_in_app = event["exception"]["values"] + reserved_properties = { + "$exception_list", + "$exception_level", + "$exception_source", + "$debug_images", + "$exception_handled", + "$exception_types", + "$exception_values", + "$exception_sources", + "$exception_functions", + "$exception_fingerprint_version", + "$exception_fingerprint_record", + "$exception_issue_id", + "$exception_release", + "$cymbal_errors", + } properties = { - "$exception_list": all_exceptions_with_trace_and_in_app, + **{ + key: value + for key, value in properties.items() + if key not in reserved_properties + }, **_get_current_otel_span_properties(), - **properties, + "$exception_list": all_exceptions_with_trace_and_in_app, + "$exception_level": _normalize_exception_level( + capture_metadata.get("level") + ) + or "error", } + source = capture_metadata.get("source") + if isinstance(source, str) and source: + properties["$exception_source"] = source context_enabled = get_capture_exception_code_variables_context() context_mask = get_code_variables_mask_patterns_context() diff --git a/posthog/exception_capture.py b/posthog/exception_capture.py index 9c4c723a9..81b33c97b 100644 --- a/posthog/exception_capture.py +++ b/posthog/exception_capture.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING from posthog.bucketed_rate_limiter import BucketedRateLimiter +from .exception_utils import _capture_exception_with_metadata if TYPE_CHECKING: from posthog.client import Client @@ -80,7 +81,17 @@ def close(self): def exception_handler(self, exc_type, exc_value, exc_traceback): if not self._closed: - self.capture_exception((exc_type, exc_value, exc_traceback)) + self._capture_exception( + (exc_type, exc_value, exc_traceback), + capture_metadata={ + "level": "fatal", + "source": "python.sys_excepthook", + "mechanism": { + "type": "onuncaughtexception", + "handled": False, + }, + }, + ) previous_hook = self._resolve_hook( self.original_excepthook, "exception_handler", @@ -90,7 +101,17 @@ def exception_handler(self, exc_type, exc_value, exc_traceback): def thread_exception_handler(self, args): if not self._closed: - self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback)) + self._capture_exception( + (args.exc_type, args.exc_value, args.exc_traceback), + capture_metadata={ + "level": "error", + "source": "python.threading_excepthook", + "mechanism": { + "type": "onuncaughtexception", + "handled": False, + }, + }, + ) previous_hook = self._resolve_hook( self._original_threading_excepthook, "thread_exception_handler", @@ -117,6 +138,9 @@ def exception_receiver(self, exc_info, extra_properties): self.capture_exception((exc_info[0], exc_info[1], exc_info[2]), metadata) def capture_exception(self, exception, metadata=None): + self._capture_exception(exception, metadata) + + def _capture_exception(self, exception, metadata=None, capture_metadata=None): try: if self._rate_limiter is not None: exception_type = self._exception_type(exception) @@ -127,7 +151,12 @@ def capture_exception(self, exception, metadata=None): return distinct_id = metadata.get("distinct_id") if metadata else None - self.client.capture_exception(exception, distinct_id=distinct_id) + _capture_exception_with_metadata( + self.client, + exception, + capture_metadata or {}, + distinct_id=distinct_id, + ) except Exception as e: self.log.exception(f"Failed to capture exception: {e}") diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index 37b0f5f20..2c582e25b 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -509,6 +509,57 @@ def get_error_message(exc_value): return safe_str(message) +def _valid_mechanism(mechanism): + # type: (Optional[Dict[str, Any]]) -> Dict[str, Any] + """Validate common mechanism fields without dropping safe extensions.""" + if not isinstance(mechanism, dict): + return {} + + result = { + key: value + for key, value in mechanism.items() + if key + not in {"type", "handled", "source", "synthetic", "exception_id", "parent_id"} + } + if isinstance(mechanism.get("type"), str) and mechanism["type"]: + result["type"] = mechanism["type"] + if isinstance(mechanism.get("handled"), bool): + result["handled"] = mechanism["handled"] + if isinstance(mechanism.get("source"), str) and mechanism["source"]: + result["source"] = mechanism["source"] + if isinstance(mechanism.get("synthetic"), bool): + result["synthetic"] = mechanism["synthetic"] + return result + + +_EXCEPTION_LEVELS = { + "fatal": "fatal", + "critical": "fatal", + "alert": "fatal", + "emergency": "fatal", + "error": "error", + "warning": "warning", + "warn": "warning", + "log": "log", + "notice": "info", + "info": "info", + "trace": "debug", + "debug": "debug", +} + + +def _normalize_exception_level(level): + # type: (Any) -> Optional[str] + return _EXCEPTION_LEVELS.get(level.lower()) if isinstance(level, str) else None + + +def _capture_exception_with_metadata(client, exception, capture_metadata, **kwargs): + # type: (Any, ExceptionArg, Dict[str, Any], **Any) -> Optional[str] + """Call capture_exception through the SDK-internal typed integration channel.""" + capture = client.capture_exception # type: Any + return capture(exception, _capture_metadata=capture_metadata, **kwargs) + + def single_exception_from_error_tuple( exc_type, # type: Optional[type] exc_value, # type: Optional[BaseException] @@ -523,9 +574,7 @@ def single_exception_from_error_tuple( Creates a dict that goes into the events `exception.values` list """ exception_value = {} # type: Dict[str, Any] - exception_value["mechanism"] = ( - mechanism.copy() if mechanism else {"type": "generic", "handled": True} - ) + exception_value["mechanism"] = _valid_mechanism(mechanism) if exception_id is not None: exception_value["mechanism"]["exception_id"] = exception_id @@ -539,16 +588,23 @@ def single_exception_from_error_tuple( "errno", {} ).setdefault("number", errno) - if source is not None: + if isinstance(source, str) and source: exception_value["mechanism"]["source"] = source is_root_exception = exception_id == 0 if not is_root_exception and parent_id is not None: exception_value["mechanism"]["parent_id"] = parent_id exception_value["mechanism"]["type"] = "chained" + exception_value["mechanism"].pop("handled", None) + + if is_root_exception: + exception_value["mechanism"].setdefault("type", "generic") + exception_value["mechanism"].setdefault("handled", True) + exception_value["mechanism"].pop("source", None) - if is_root_exception and "type" not in exception_value["mechanism"]: - exception_value["mechanism"]["type"] = "generic" + # 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) is_exception_group = BaseExceptionGroup is not None and isinstance( exc_value, BaseExceptionGroup @@ -618,7 +674,7 @@ def walk_exception_chain(exc_info): yield exc_info -def exceptions_from_error( +def _exceptions_from_error( exc_type, # type: Optional[type] exc_value, # type: Optional[BaseException] tb, # type: Optional[TracebackType] @@ -626,6 +682,7 @@ def exceptions_from_error( exception_id=0, # type: int parent_id=0, # type: int source=None, # type: Optional[str] + seen_exception_ids=None, # type: Optional[Set[int]] ): # type: (...) -> Tuple[int, List[Dict[str, Any]]] """ @@ -633,6 +690,13 @@ def exceptions_from_error( This can include chained exceptions and exceptions from an ExceptionGroup. """ + if seen_exception_ids is None: + seen_exception_ids = set() + if exc_value is not None: + if id(exc_value) in seen_exception_ids or exception_id >= 50: + return (exception_id, []) + seen_exception_ids.add(id(exc_value)) + parent = single_exception_from_error_tuple( exc_type=exc_type, exc_value=exc_value, @@ -647,67 +711,74 @@ def exceptions_from_error( parent_id = exception_id exception_id += 1 - should_supress_context = ( + causing_exception = None # type: Optional[BaseException] + relationship = None # type: Optional[str] + should_suppress_context = ( hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore ) - if should_supress_context: - # Add direct cause. - # The field `__cause__` is set when raised with the exception (using the `from` keyword). - exception_has_cause = ( - exc_value - and hasattr(exc_value, "__cause__") - and exc_value.__cause__ is not None - ) - if exception_has_cause: - cause = exc_value.__cause__ # type: ignore - (exception_id, child_exceptions) = exceptions_from_error( - exc_type=type(cause), - exc_value=cause, - tb=getattr(cause, "__traceback__", None), - mechanism=mechanism, - exception_id=exception_id, - source="__cause__", - ) - exceptions.extend(child_exceptions) - + if should_suppress_context and exc_value is not None: + causing_exception = getattr(exc_value, "__cause__", None) + relationship = "cause" else: - # Add indirect cause. - # The field `__context__` is assigned if another exception occurs while handling the exception. - exception_has_content = ( - exc_value - and hasattr(exc_value, "__context__") - and exc_value.__context__ is not None + causing_exception = getattr(exc_value, "__context__", None) + relationship = "context" + + if causing_exception is not None and exception_id < 50: + (exception_id, child_exceptions) = _exceptions_from_error( + exc_type=type(causing_exception), + exc_value=causing_exception, + tb=getattr(causing_exception, "__traceback__", None), + mechanism=None, + exception_id=exception_id, + parent_id=parent_id, + source=relationship, + seen_exception_ids=seen_exception_ids, ) - if exception_has_content: - context = exc_value.__context__ # type: ignore - (exception_id, child_exceptions) = exceptions_from_error( - exc_type=type(context), - exc_value=context, - tb=getattr(context, "__traceback__", None), - mechanism=mechanism, - exception_id=exception_id, - source="__context__", - ) - exceptions.extend(child_exceptions) + exceptions.extend(child_exceptions) # Add exceptions from an ExceptionGroup. is_exception_group = exc_value and hasattr(exc_value, "exceptions") if is_exception_group: - for idx, e in enumerate(exc_value.exceptions): # type: ignore - (exception_id, child_exceptions) = exceptions_from_error( + for e in exc_value.exceptions: # type: ignore + if exception_id >= 50: + break + (exception_id, child_exceptions) = _exceptions_from_error( exc_type=type(e), exc_value=e, tb=getattr(e, "__traceback__", None), - mechanism=mechanism, + mechanism=None, exception_id=exception_id, parent_id=parent_id, - source="exceptions[%s]" % idx, + source="member", + seen_exception_ids=seen_exception_ids, ) exceptions.extend(child_exceptions) return (exception_id, exceptions) +def exceptions_from_error( + exc_type, # type: Optional[type] + exc_value, # type: Optional[BaseException] + tb, # type: Optional[TracebackType] + mechanism=None, # type: Optional[Dict[str, Any]] + exception_id=0, # type: int + parent_id=0, # type: int + source=None, # type: Optional[str] +): + # type: (...) -> Tuple[int, List[Dict[str, Any]]] + """Compatibility wrapper around the bounded exception-tree traversal.""" + return _exceptions_from_error( + exc_type, + exc_value, + tb, + mechanism=mechanism, + exception_id=exception_id, + parent_id=parent_id, + source=source, + ) + + def exceptions_from_error_tuple( exc_info, # type: ExcInfo mechanism=None, # type: Optional[Dict[str, Any]] @@ -715,27 +786,15 @@ def exceptions_from_error_tuple( # type: (...) -> List[Dict[str, Any]] exc_type, exc_value, tb = exc_info - is_exception_group = BaseExceptionGroup is not None and isinstance( - exc_value, BaseExceptionGroup + (_, exceptions) = _exceptions_from_error( + exc_type=exc_type, + exc_value=exc_value, + tb=tb, + mechanism=mechanism, + exception_id=0, + parent_id=0, ) - if is_exception_group: - (_, exceptions) = exceptions_from_error( - exc_type=exc_type, - exc_value=exc_value, - tb=tb, - mechanism=mechanism, - exception_id=0, - parent_id=0, - ) - - else: - exceptions = [] - for exc_type, exc_value, tb in walk_exception_chain(exc_info): - exceptions.append( - single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism) - ) - # 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 diff --git a/posthog/integrations/celery.py b/posthog/integrations/celery.py index bcd7b140f..29d60353e 100644 --- a/posthog/integrations/celery.py +++ b/posthog/integrations/celery.py @@ -67,10 +67,11 @@ import json import logging import time -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, cast from .. import contexts from ..client import Client +from ..exception_utils import _capture_exception_with_metadata CONTEXT_DISTINCT_ID_HEADER = "X-POSTHOG-DISTINCT-ID" @@ -475,12 +476,17 @@ def _capture_event(self, event: str, properties: dict[str, Any]) -> None: capture(event, properties=properties) def _capture_exception(self, exception: Exception) -> None: + capture_metadata = { + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + } if self.client: - self.client.capture_exception(exception) + _capture_exception_with_metadata(self.client, exception, capture_metadata) else: from posthog import capture_exception - capture_exception(exception) + cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata) __all__ = [ diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 6eba55058..3c6f9af04 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -1,8 +1,9 @@ import re -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast from .. import contexts from ..client import Client +from ..exception_utils import _capture_exception_with_metadata try: from asgiref.sync import iscoroutinefunction, markcoroutinefunction @@ -362,9 +363,14 @@ def process_exception(self, request, exception): # Context and tags already set by __call__ or __acall__ # Just capture the exception + capture_metadata = { + "level": "error", + "source": "django.middleware", + "mechanism": {"type": "middleware", "handled": False}, + } if self.client: - self.client.capture_exception(exception) + _capture_exception_with_metadata(self.client, exception, capture_metadata) else: from posthog import capture_exception - capture_exception(exception) + cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata) diff --git a/posthog/test/integrations/test_celery_integration.py b/posthog/test/integrations/test_celery_integration.py index 3ef0b4160..77af004e9 100644 --- a/posthog/test/integrations/test_celery_integration.py +++ b/posthog/test/integrations/test_celery_integration.py @@ -414,7 +414,14 @@ def test_task_failure_captures_exception_and_failure_event(self): exception=exception, ) - mock_client.capture_exception.assert_called_once_with(exception) + mock_client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + }, + ) event_names = [call.args[0] for call in mock_client.capture.call_args_list] self.assertIn("celery task failure", event_names) @@ -578,7 +585,14 @@ def test_task_failure_captures_exception_when_lifecycle_events_disabled(self): ) mock_client.capture.assert_not_called() - mock_client.capture_exception.assert_called_once_with(exception) + mock_client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + }, + ) def test_after_task_publish_captures_published_event(self): mock_client = Mock() @@ -646,7 +660,14 @@ def test_capture_exception_falls_back_to_global_capture_exception(self): with patch("posthog.capture_exception") as mock_capture_exception: integration._capture_exception(exception) - mock_capture_exception.assert_called_once_with(exception) + mock_capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "celery.task_failure", + "mechanism": {"type": "task", "handled": False}, + }, + ) def test_extract_headers_supports_request_dict_shape(self): integration = PosthogCeleryIntegration() diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index 22ce948c9..a772c2083 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -308,7 +308,14 @@ def mock_get_response(request): response = middleware(request) self.assertEqual(response.status_code, 500) - mock_client.capture_exception.assert_called_once_with(view_exception) + mock_client.capture_exception.assert_called_once_with( + view_exception, + _capture_metadata={ + "level": "error", + "source": "django.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) def test_process_exception_respects_capture_exceptions_false(self): """Verify process_exception respects capture_exceptions=False setting""" @@ -444,7 +451,14 @@ def get_response_simulating_django(request): if hasattr(middleware, "process_exception"): exception = ValueError("View error") middleware.process_exception(request, exception) - mock_client.capture_exception.assert_called_once_with(exception) + mock_client.capture_exception.assert_called_once_with( + exception, + _capture_metadata={ + "level": "error", + "source": "django.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) else: self.fail( "process_exception missing - view exceptions will not be captured!" diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index 85b50ac16..10a34359e 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -157,6 +157,16 @@ def test_exception_hooks_delegate_and_restore_previous_hooks(monkeypatch): capture.close() assert client.capture_exception.call_count == 2 + assert client.capture_exception.call_args_list[0].kwargs["_capture_metadata"] == { + "level": "fatal", + "source": "python.sys_excepthook", + "mechanism": {"type": "onuncaughtexception", "handled": False}, + } + assert client.capture_exception.call_args_list[1].kwargs["_capture_metadata"] == { + "level": "error", + "source": "python.threading_excepthook", + "mechanism": {"type": "onuncaughtexception", "handled": False}, + } sys_hook.assert_called_once_with(*exc_info) thread_hook.assert_called_once_with(thread_args) assert sys.excepthook is sys_hook @@ -248,7 +258,7 @@ def test_uncaught_thread_exception_preserves_default_diagnostic(): from posthog.exception_capture import ExceptionCapture class Client: - def capture_exception(self, exception, distinct_id=None): + def capture_exception(self, exception, distinct_id=None, _capture_metadata=None): print(f"captured:{exception[0].__name__}") capture = ExceptionCapture(Client()) @@ -295,10 +305,10 @@ def test_excepthook(tmpdir): assert b"ZeroDivisionError" in output assert b"LOL" in output assert b"DEBUG:posthog:[PostHog] data uploaded successfully" in output - assert ( - 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 - ) + assert b'"$exception_level": "fatal"' in output + assert b'"$exception_source": "python.sys_excepthook"' in output + assert b'"type": "onuncaughtexception"' in output + assert b'"handled": false' in output class _RootError(Exception): @@ -337,6 +347,19 @@ def test_exception_list_canonical_order_explicit_cause(): assert types == ["_WrapperError", "_RootError"] assert exceptions[0]["value"] == "wrapper" assert exceptions[-1]["value"] == "root" + assert exceptions[0]["mechanism"] == { + "type": "generic", + "handled": True, + "synthetic": False, + "exception_id": 0, + } + assert exceptions[1]["mechanism"] == { + "type": "chained", + "source": "cause", + "synthetic": False, + "exception_id": 1, + "parent_id": 0, + } def test_exception_list_canonical_order_implicit_context(): @@ -358,6 +381,7 @@ def test_exception_list_canonical_order_implicit_context(): assert types == ["_WrapperError", "_RootError"] assert exceptions[0]["value"] == "wrapper" assert exceptions[-1]["value"] == "root" + assert exceptions[1]["mechanism"]["source"] == "context" @pytest.mark.skipif( From fd1d0beda47e5d834c16c859ed7f852f59af4a26 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Wed, 26 Aug 2026 11:00:54 +0200 Subject: [PATCH 2/3] fix(error-tracking): preserve explicit trace context --- posthog/client.py | 2 +- posthog/test/snapshots/exception_event.json | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 1f66e0e5b..b7a6253ca 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2097,12 +2097,12 @@ def capture_exception( "$cymbal_errors", } properties = { + **_get_current_otel_span_properties(), **{ key: value for key, value in properties.items() if key not in reserved_properties }, - **_get_current_otel_span_properties(), "$exception_list": all_exceptions_with_trace_and_in_app, "$exception_level": _normalize_exception_level( capture_metadata.get("level") diff --git a/posthog/test/snapshots/exception_event.json b/posthog/test/snapshots/exception_event.json index b971c7afe..aef9d628e 100644 --- a/posthog/test/snapshots/exception_event.json +++ b/posthog/test/snapshots/exception_event.json @@ -6,10 +6,13 @@ "distinct_id": "user-123", "event": "$exception", "properties": { + "$exception_level": "error", "$exception_list": [ { "mechanism": { + "exception_id": 0, "handled": true, + "synthetic": false, "type": "generic" }, "module": null, @@ -71,8 +74,11 @@ }, { "mechanism": { - "handled": true, - "type": "generic" + "exception_id": 1, + "parent_id": 0, + "source": "cause", + "synthetic": false, + "type": "chained" }, "module": null, "stacktrace": { From 0fbbfc4da857024c7110f3da84870be1dbe14a31 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Wed, 26 Aug 2026 12:07:52 +0200 Subject: [PATCH 3/3] fix(error-tracking): restrict exception group traversal --- posthog/exception_utils.py | 4 ++- posthog/test/test_exception_capture.py | 38 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index 2c582e25b..db0410270 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -737,7 +737,9 @@ def _exceptions_from_error( exceptions.extend(child_exceptions) # Add exceptions from an ExceptionGroup. - is_exception_group = exc_value and hasattr(exc_value, "exceptions") + is_exception_group = BaseExceptionGroup is not None and isinstance( + exc_value, BaseExceptionGroup + ) if is_exception_group: for e in exc_value.exceptions: # type: ignore if exception_id >= 50: diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index 10a34359e..bd02cfbbf 100644 --- a/posthog/test/test_exception_capture.py +++ b/posthog/test/test_exception_capture.py @@ -327,6 +327,10 @@ class _LeafTwo(Exception): pass +class _ExceptionWithMetadata(Exception): + exceptions = 1 + + 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 @@ -384,6 +388,19 @@ def test_exception_list_canonical_order_implicit_context(): assert exceptions[1]["mechanism"]["source"] == "context" +def test_ordinary_exception_does_not_treat_exceptions_attribute_as_group_members(): + from posthog.exception_utils import exceptions_from_error_tuple + + try: + raise _ExceptionWithMetadata("ordinary") + except _ExceptionWithMetadata: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + assert [exception["type"] for exception in exceptions] == ["_ExceptionWithMetadata"] + + @pytest.mark.skipif( sys.version_info < (3, 11), reason="ExceptionGroup requires Python 3.11+", @@ -405,3 +422,24 @@ def test_exception_list_canonical_order_exception_group(): types = [e["type"] for e in exceptions] assert types[0] == "ExceptionGroup" assert types[1:] == ["_LeafOne", "_LeafTwo"] + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="ExceptionGroup requires Python 3.11+", +) +def test_exception_group_serializes_a_repeated_object_only_once(): + from posthog.exception_utils import exceptions_from_error_tuple + + shared = _LeafOne("shared") + try: + raise ExceptionGroup("group", [shared, shared]) # noqa: F821 + except BaseException: + exc_info = sys.exc_info() + + exceptions = exceptions_from_error_tuple(exc_info) + + assert [exception["value"] for exception in exceptions] == [ + "group", + "shared", + ]