From 39070babfbd58fa40b6e2d1da2e05d664c6dad18 Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Mon, 9 Sep 2024 11:40:51 +0100 Subject: [PATCH] feat(errors): Add manual exception capture (#134) * feat(errors): Add manual exception capture * prep release * use backwards compatible helper * add tests --- CHANGELOG.md | 4 + posthog/__init__.py | 46 +++++++++- posthog/client.py | 61 +++++++++++++- posthog/exception_capture.py | 34 +------- posthog/test/test_client.py | 158 +++++++++++++++++++++++++++++++++++ posthog/version.py | 2 +- 6 files changed, 269 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a2c6e..d3142e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.6.4 - 2024-09-05 + +1. Add manual exception capture. + ## 3.6.3 - 2024-09-03 1. Make sure setup.py for posthoganalytics package also discovers the new exception integration package. diff --git a/posthog/__init__.py b/posthog/__init__.py index 1c0de18..ab2aba1 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -2,7 +2,7 @@ from typing import Callable, Dict, List, Optional, Tuple # noqa: F401 from posthog.client import Client -from posthog.exception_capture import Integrations # noqa: F401 +from posthog.exception_capture import DEFAULT_DISTINCT_ID, Integrations # noqa: F401 from posthog.version import VERSION __version__ = VERSION @@ -251,6 +251,50 @@ def alias( ) +def capture_exception( + exception=None, # type: Optional[BaseException] + distinct_id=None, # type: Optional[str] + properties=None, # type: Optional[Dict] + context=None, # type: Optional[Dict] + timestamp=None, # type: Optional[datetime.datetime] + uuid=None, # type: Optional[str] + groups=None, # type: Optional[Dict] +): + # type: (...) -> Tuple[bool, dict] + """ + capture_exception allows you to capture exceptions that happen in your code. This is useful for debugging and understanding what errors your users are encountering. + This function never raises an exception, even if it fails to send the event. + + A `capture_exception` call does not require any fields, but we recommend sending: + - `distinct id` which uniquely identifies your user for which this exception happens + - `exception` to specify the exception to capture. If not provided, the current exception is captured via `sys.exc_info()` + + Optionally you can submit + - `properties`, which can be a dict with any information you'd like to add + - `groups`, which is a dict of group type -> group key mappings + + For example: + ```python + try: + 1 / 0 + except Exception as e: + posthog.capture_exception(e, 'my specific distinct id') + posthog.capture_exception(distinct_id='my specific distinct id') + + ``` + """ + return _proxy( + "capture_exception", + exception=exception, + distinct_id=distinct_id or DEFAULT_DISTINCT_ID, + properties=properties, + context=context, + timestamp=timestamp, + uuid=uuid, + groups=groups, + ) + + def feature_enabled( key, # type: str distinct_id, # type: str diff --git a/posthog/client.py b/posthog/client.py index 7aeb2b5..0f7c833 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1,6 +1,7 @@ import atexit import logging import numbers +import sys from datetime import datetime, timedelta from uuid import UUID @@ -8,11 +9,12 @@ from six import string_types from posthog.consumer import Consumer -from posthog.exception_capture import ExceptionCapture +from posthog.exception_capture import DEFAULT_DISTINCT_ID, ExceptionCapture +from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties from posthog.poller import Poller -from posthog.request import APIError, batch_post, decide, determine_server_host, get -from posthog.utils import SizeLimitedDict, clean, guess_timezone +from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get +from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash from posthog.version import VERSION try: @@ -67,7 +69,7 @@ def __init__( self.send = send self.sync_mode = sync_mode # Used for session replay URL generation - we don't want the server host here. - self.raw_host = host + self.raw_host = host or DEFAULT_HOST self.host = determine_server_host(host) self.gzip = gzip self.timeout = timeout @@ -345,6 +347,57 @@ def page( return self._enqueue(msg, disable_geoip) + def capture_exception( + self, + exception=None, + distinct_id=DEFAULT_DISTINCT_ID, + properties=None, + context=None, + timestamp=None, + uuid=None, + groups=None, + ): + # this function shouldn't ever throw an error, so it logs exceptions instead of raising them. + # this is important to ensure we don't unexpectedly re-raise exceptions in the user's code. + try: + properties = properties or {} + require("distinct_id", distinct_id, ID_TYPES) + require("properties", properties, dict) + + if exception is not None: + exc_info = exc_info_from_error(exception) + else: + exc_info = sys.exc_info() + + if exc_info is None or exc_info == (None, None, None): + self.log.warning("No exception information available") + return + + # Format stack trace like sentry + all_exceptions_with_trace = exceptions_from_error_tuple(exc_info) + + # Add in-app property to frames in the exceptions + event = handle_in_app( + { + "exception": { + "values": all_exceptions_with_trace, + }, + } + ) + all_exceptions_with_trace_and_in_app = event["exception"]["values"] + + properties = { + "$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"), + "$exception_message": all_exceptions_with_trace_and_in_app[0].get("value"), + "$exception_list": all_exceptions_with_trace_and_in_app, + "$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}", + **properties, + } + + return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups) + except Exception as e: + self.log.exception(f"Failed to capture exception: {e}") + def _enqueue(self, msg, disable_geoip): """Push a new `msg` onto the queue, return `(success, msg)`""" diff --git a/posthog/exception_capture.py b/posthog/exception_capture.py index 84222d8..053451b 100644 --- a/posthog/exception_capture.py +++ b/posthog/exception_capture.py @@ -4,9 +4,6 @@ from enum import Enum from typing import TYPE_CHECKING, List, Optional -from posthog.exception_utils import exceptions_from_error_tuple, handle_in_app -from posthog.utils import remove_trailing_slash - if TYPE_CHECKING: from posthog.client import Client @@ -49,11 +46,11 @@ def close(self): def exception_handler(self, exc_type, exc_value, exc_traceback): # don't affect default behaviour. - self.capture_exception(exc_type, exc_value, exc_traceback) + self.capture_exception((exc_type, exc_value, exc_traceback)) self.original_excepthook(exc_type, exc_value, exc_traceback) def thread_exception_handler(self, args): - self.capture_exception(args.exc_type, args.exc_value, args.exc_traceback) + self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback)) def exception_receiver(self, exc_info, extra_properties): if "distinct_id" in extra_properties: @@ -62,39 +59,16 @@ def exception_receiver(self, exc_info, extra_properties): metadata = None self.capture_exception(exc_info[0], exc_info[1], exc_info[2], metadata) - def capture_exception(self, exc_type, exc_value, exc_traceback, metadata=None): + def capture_exception(self, exception, metadata=None): try: # if hasattr(sys, "ps1"): # # Disable the excepthook for interactive Python shells # return - # Format stack trace like sentry - all_exceptions_with_trace = exceptions_from_error_tuple((exc_type, exc_value, exc_traceback)) - - # Add in-app property to frames in the exceptions - event = handle_in_app( - { - "exception": { - "values": all_exceptions_with_trace, - }, - } - ) - all_exceptions_with_trace_and_in_app = event["exception"]["values"] - distinct_id = metadata.get("distinct_id") if metadata else DEFAULT_DISTINCT_ID # Make sure we have a distinct_id if its empty in metadata distinct_id = distinct_id or DEFAULT_DISTINCT_ID - properties = { - "$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"), - "$exception_message": all_exceptions_with_trace_and_in_app[0].get("value"), - "$exception_list": all_exceptions_with_trace_and_in_app, - "$exception_personURL": f"{remove_trailing_slash(self.client.raw_host)}/project/{self.client.api_key}/person/{distinct_id}", - } - - # TODO: What distinct id should we attach these server-side exceptions to? - # Any heuristic seems prone to errors - how can we know if exception occurred in the context of a user that captured some other event? - - self.client.capture(distinct_id, "$exception", properties=properties) + self.client.capture_exception(exception, distinct_id) except Exception as e: self.log.exception(f"Failed to capture exception: {e}") diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 0a9e63a..b7c89db 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -84,6 +84,164 @@ def test_basic_capture_with_project_api_key(self): self.assertEqual(msg["properties"]["$lib"], "posthog-python") self.assertEqual(msg["properties"]["$lib_version"], VERSION) + def test_basic_capture_exception(self): + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + client = self.client + exception = Exception("test exception") + client.capture_exception(exception) + + self.assertTrue(patch_capture.called) + capture_call = patch_capture.call_args[0] + self.assertEqual(capture_call[0], "python-exceptions") + self.assertEqual(capture_call[1], "$exception") + self.assertEqual( + capture_call[2], + { + "$exception_type": "Exception", + "$exception_message": "test exception", + "$exception_list": [ + { + "mechanism": {"type": "generic", "handled": True}, + "module": None, + "type": "Exception", + "value": "test exception", + } + ], + "$exception_personURL": "https://us.i.posthog.com/project/random_key/person/python-exceptions", + }, + ) + + def test_basic_capture_exception_with_distinct_id(self): + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + client = self.client + exception = Exception("test exception") + client.capture_exception(exception, "distinct_id") + + self.assertTrue(patch_capture.called) + capture_call = patch_capture.call_args[0] + self.assertEqual(capture_call[0], "distinct_id") + self.assertEqual(capture_call[1], "$exception") + self.assertEqual( + capture_call[2], + { + "$exception_type": "Exception", + "$exception_message": "test exception", + "$exception_list": [ + { + "mechanism": {"type": "generic", "handled": True}, + "module": None, + "type": "Exception", + "value": "test exception", + } + ], + "$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id", + }, + ) + + def test_basic_capture_exception_with_correct_host_generation(self): + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com") + exception = Exception("test exception") + client.capture_exception(exception, "distinct_id") + + self.assertTrue(patch_capture.called) + capture_call = patch_capture.call_args[0] + self.assertEqual(capture_call[0], "distinct_id") + self.assertEqual(capture_call[1], "$exception") + self.assertEqual( + capture_call[2], + { + "$exception_type": "Exception", + "$exception_message": "test exception", + "$exception_list": [ + { + "mechanism": {"type": "generic", "handled": True}, + "module": None, + "type": "Exception", + "value": "test exception", + } + ], + "$exception_personURL": "https://aloha.com/project/random_key/person/distinct_id", + }, + ) + + def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(self): + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com") + exception = Exception("test exception") + client.capture_exception(exception, "distinct_id") + + self.assertTrue(patch_capture.called) + capture_call = patch_capture.call_args[0] + self.assertEqual(capture_call[0], "distinct_id") + self.assertEqual(capture_call[1], "$exception") + self.assertEqual( + capture_call[2], + { + "$exception_type": "Exception", + "$exception_message": "test exception", + "$exception_list": [ + { + "mechanism": {"type": "generic", "handled": True}, + "module": None, + "type": "Exception", + "value": "test exception", + } + ], + "$exception_personURL": "https://app.posthog.com/project/random_key/person/distinct_id", + }, + ) + + def test_basic_capture_exception_with_no_exception_given(self): + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + client = self.client + try: + raise Exception("test exception") + except Exception: + client.capture_exception() + + self.assertTrue(patch_capture.called) + capture_call = patch_capture.call_args[0] + self.assertEqual(capture_call[0], "python-exceptions") + self.assertEqual(capture_call[1], "$exception") + self.assertEqual(capture_call[2]["$exception_type"], "Exception") + self.assertEqual(capture_call[2]["$exception_message"], "test exception") + self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["type"], "generic") + self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["handled"], True) + self.assertEqual(capture_call[2]["$exception_list"][0]["module"], None) + self.assertEqual(capture_call[2]["$exception_list"][0]["type"], "Exception") + self.assertEqual(capture_call[2]["$exception_list"][0]["value"], "test exception") + self.assertEqual( + capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["filename"], + "posthog/test/test_client.py", + ) + self.assertEqual( + capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["function"], + "test_basic_capture_exception_with_no_exception_given", + ) + self.assertEqual( + capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["module"], "posthog.test.test_client" + ) + + def test_basic_capture_exception_with_no_exception_happening(self): + + with mock.patch.object(Client, "capture", return_value=None) as patch_capture: + with self.assertLogs("posthog", level="WARNING") as logs: + + client = self.client + client.capture_exception() + + self.assertFalse(patch_capture.called) + self.assertEqual( + logs.output[0], + "WARNING:posthog:No exception information available", + ) + @mock.patch("posthog.client.decide") def test_basic_capture_with_feature_flags(self, patch_decide): patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}} diff --git a/posthog/version.py b/posthog/version.py index 5de2922..3196ca2 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "3.6.3" +VERSION = "3.6.4" if __name__ == "__main__": print(VERSION, end="") # noqa: T201