diff --git a/.sampo/changesets/canonical-exception-metadata.md b/.sampo/changesets/canonical-exception-metadata.md new file mode 100644 index 000000000..67c9ed3e1 --- /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, and deterministic cause linkage. Application overrides of reserved exception properties remain supported during a deprecation period, emit a warning, and will be removed in the next major version. diff --git a/posthog/__init__.py b/posthog/__init__.py index 9729d2a64..4cda34495 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -711,6 +711,8 @@ def capture_exception( exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()` **kwargs: Optional capture arguments including distinct_id, properties, timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip. + Overriding reserved exception properties through ``properties`` is + deprecated and will stop working in the next major version. Details: Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`. diff --git a/posthog/client.py b/posthog/client.py index dcdae7cd1..2c55d0c1a 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 ( @@ -2013,7 +2014,9 @@ def capture_exception( Args: exception: The exception to capture. distinct_id: The distinct ID of the user. - properties: A dictionary of additional properties. + properties: A dictionary of additional properties. Overriding reserved + exception properties is deprecated and will stop working in the next + major version. flags: A ``FeatureFlagEvaluations`` snapshot from ``evaluate_flags()``. Attaches those exact flag values to the captured `$exception` event. send_feature_flags: Deprecated. Pass ``flags`` from ``evaluate_flags()`` instead. @@ -2056,7 +2059,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 +2082,52 @@ 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", + } + reserved_property_overrides = reserved_properties.intersection(properties) + if reserved_property_overrides: + try: + warnings.warn( + "Reserved exception properties passed through " + "`capture_exception(properties=...)` currently override " + "SDK-owned metadata, but this behavior is deprecated and will " + "be removed in the next major version: " + + ", ".join(sorted(reserved_property_overrides)), + DeprecationWarning, + stacklevel=2, + ) + except DeprecationWarning: + # capture_exception must not drop an event when applications + # promote deprecation warnings to errors. + pass + + caller_properties = properties properties = { - "$exception_list": all_exceptions_with_trace_and_in_app, **_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 + properties.update(caller_properties) 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..db0410270 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 and "type" not in exception_value["mechanism"]: - exception_value["mechanism"]["type"] = "generic" + if is_root_exception: + exception_value["mechanism"].setdefault("type", "generic") + 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) 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,76 @@ 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") + is_exception_group = BaseExceptionGroup is not None and isinstance( + exc_value, BaseExceptionGroup + ) 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 +788,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/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": { diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 087a79ca1..9c8666e5a 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -573,6 +573,45 @@ def test_basic_capture_exception(self): self.assertEqual(capture_call[0][0], "$exception") self.assertEqual(capture_call[1]["distinct_id"], "distinct_id") + def test_reserved_exception_property_overrides_are_deprecated(self): + custom_exception_list = [{"type": "CustomError", "value": "custom"}] + properties = { + "$exception_list": custom_exception_list, + "$exception_level": "warning", + "$exception_source": "custom.source", + "$exception_issue_id": "legacy-issue-id", + } + + with ( + mock.patch.object(Client, "capture", return_value=None) as patch_capture, + self.assertWarnsRegex( + DeprecationWarning, + "Reserved exception properties.*next major version", + ), + ): + self.client.capture_exception( + Exception("test exception"), properties=properties + ) + + captured_properties = patch_capture.call_args.kwargs["properties"] + self.assertIs(captured_properties["$exception_list"], custom_exception_list) + self.assertEqual(captured_properties["$exception_level"], "warning") + self.assertEqual(captured_properties["$exception_source"], "custom.source") + self.assertEqual(captured_properties["$exception_issue_id"], "legacy-issue-id") + + def test_reserved_exception_property_warning_cannot_drop_the_event(self): + with ( + mock.patch.object(Client, "capture", return_value=None) as patch_capture, + warnings.catch_warnings(), + ): + warnings.simplefilter("error", DeprecationWarning) + self.client.capture_exception( + Exception("test exception"), + properties={"$exception_level": "warning"}, + ) + + patch_capture.assert_called_once() + @parameterized.expand( [ ( diff --git a/posthog/test/test_exception_capture.py b/posthog/test/test_exception_capture.py index 85b50ac16..bd02cfbbf 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): @@ -317,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 @@ -337,6 +351,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 +385,20 @@ 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" + + +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( @@ -381,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", + ]