Skip to content
Open
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-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
61 changes: 57 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -2070,11 +2082,52 @@ def capture_exception(
)
all_exceptions_with_trace_and_in_app = event["exception"]["values"]

reserved_properties = {
Comment thread
ablaszkiewicz marked this conversation as resolved.
"$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()
Expand Down
35 changes: 32 additions & 3 deletions posthog/exception_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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}")

Expand Down
Loading