Skip to content

Commit

Permalink
feat(errors): Add manual exception capture (#134)
Browse files Browse the repository at this point in the history
* feat(errors): Add manual exception capture

* prep release

* use backwards compatible helper

* add tests
  • Loading branch information
neilkakkar authored Sep 9, 2024
1 parent 716eab0 commit 39070ba
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 36 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
46 changes: 45 additions & 1 deletion posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 57 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import atexit
import logging
import numbers
import sys
from datetime import datetime, timedelta
from uuid import UUID

from dateutil.tz import tzutc
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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)`"""

Expand Down
34 changes: 4 additions & 30 deletions posthog/exception_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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}")
158 changes: 158 additions & 0 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
2 changes: 1 addition & 1 deletion posthog/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION = "3.6.3"
VERSION = "3.6.4"

if __name__ == "__main__":
print(VERSION, end="") # noqa: T201

0 comments on commit 39070ba

Please sign in to comment.