Skip to content

Commit 39070ba

Browse files
authored
feat(errors): Add manual exception capture (#134)
* feat(errors): Add manual exception capture * prep release * use backwards compatible helper * add tests
1 parent 716eab0 commit 39070ba

File tree

6 files changed

+269
-36
lines changed

6 files changed

+269
-36
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 3.6.4 - 2024-09-05
2+
3+
1. Add manual exception capture.
4+
15
## 3.6.3 - 2024-09-03
26

37
1. Make sure setup.py for posthoganalytics package also discovers the new exception integration package.

posthog/__init__.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
33

44
from posthog.client import Client
5-
from posthog.exception_capture import Integrations # noqa: F401
5+
from posthog.exception_capture import DEFAULT_DISTINCT_ID, Integrations # noqa: F401
66
from posthog.version import VERSION
77

88
__version__ = VERSION
@@ -251,6 +251,50 @@ def alias(
251251
)
252252

253253

254+
def capture_exception(
255+
exception=None, # type: Optional[BaseException]
256+
distinct_id=None, # type: Optional[str]
257+
properties=None, # type: Optional[Dict]
258+
context=None, # type: Optional[Dict]
259+
timestamp=None, # type: Optional[datetime.datetime]
260+
uuid=None, # type: Optional[str]
261+
groups=None, # type: Optional[Dict]
262+
):
263+
# type: (...) -> Tuple[bool, dict]
264+
"""
265+
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.
266+
This function never raises an exception, even if it fails to send the event.
267+
268+
A `capture_exception` call does not require any fields, but we recommend sending:
269+
- `distinct id` which uniquely identifies your user for which this exception happens
270+
- `exception` to specify the exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
271+
272+
Optionally you can submit
273+
- `properties`, which can be a dict with any information you'd like to add
274+
- `groups`, which is a dict of group type -> group key mappings
275+
276+
For example:
277+
```python
278+
try:
279+
1 / 0
280+
except Exception as e:
281+
posthog.capture_exception(e, 'my specific distinct id')
282+
posthog.capture_exception(distinct_id='my specific distinct id')
283+
284+
```
285+
"""
286+
return _proxy(
287+
"capture_exception",
288+
exception=exception,
289+
distinct_id=distinct_id or DEFAULT_DISTINCT_ID,
290+
properties=properties,
291+
context=context,
292+
timestamp=timestamp,
293+
uuid=uuid,
294+
groups=groups,
295+
)
296+
297+
254298
def feature_enabled(
255299
key, # type: str
256300
distinct_id, # type: str

posthog/client.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import atexit
22
import logging
33
import numbers
4+
import sys
45
from datetime import datetime, timedelta
56
from uuid import UUID
67

78
from dateutil.tz import tzutc
89
from six import string_types
910

1011
from posthog.consumer import Consumer
11-
from posthog.exception_capture import ExceptionCapture
12+
from posthog.exception_capture import DEFAULT_DISTINCT_ID, ExceptionCapture
13+
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
1214
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
1315
from posthog.poller import Poller
14-
from posthog.request import APIError, batch_post, decide, determine_server_host, get
15-
from posthog.utils import SizeLimitedDict, clean, guess_timezone
16+
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get
17+
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
1618
from posthog.version import VERSION
1719

1820
try:
@@ -67,7 +69,7 @@ def __init__(
6769
self.send = send
6870
self.sync_mode = sync_mode
6971
# Used for session replay URL generation - we don't want the server host here.
70-
self.raw_host = host
72+
self.raw_host = host or DEFAULT_HOST
7173
self.host = determine_server_host(host)
7274
self.gzip = gzip
7375
self.timeout = timeout
@@ -345,6 +347,57 @@ def page(
345347

346348
return self._enqueue(msg, disable_geoip)
347349

350+
def capture_exception(
351+
self,
352+
exception=None,
353+
distinct_id=DEFAULT_DISTINCT_ID,
354+
properties=None,
355+
context=None,
356+
timestamp=None,
357+
uuid=None,
358+
groups=None,
359+
):
360+
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
361+
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
362+
try:
363+
properties = properties or {}
364+
require("distinct_id", distinct_id, ID_TYPES)
365+
require("properties", properties, dict)
366+
367+
if exception is not None:
368+
exc_info = exc_info_from_error(exception)
369+
else:
370+
exc_info = sys.exc_info()
371+
372+
if exc_info is None or exc_info == (None, None, None):
373+
self.log.warning("No exception information available")
374+
return
375+
376+
# Format stack trace like sentry
377+
all_exceptions_with_trace = exceptions_from_error_tuple(exc_info)
378+
379+
# Add in-app property to frames in the exceptions
380+
event = handle_in_app(
381+
{
382+
"exception": {
383+
"values": all_exceptions_with_trace,
384+
},
385+
}
386+
)
387+
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
388+
389+
properties = {
390+
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
391+
"$exception_message": all_exceptions_with_trace_and_in_app[0].get("value"),
392+
"$exception_list": all_exceptions_with_trace_and_in_app,
393+
"$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}",
394+
**properties,
395+
}
396+
397+
return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
398+
except Exception as e:
399+
self.log.exception(f"Failed to capture exception: {e}")
400+
348401
def _enqueue(self, msg, disable_geoip):
349402
"""Push a new `msg` onto the queue, return `(success, msg)`"""
350403

posthog/exception_capture.py

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
from enum import Enum
55
from typing import TYPE_CHECKING, List, Optional
66

7-
from posthog.exception_utils import exceptions_from_error_tuple, handle_in_app
8-
from posthog.utils import remove_trailing_slash
9-
107
if TYPE_CHECKING:
118
from posthog.client import Client
129

@@ -49,11 +46,11 @@ def close(self):
4946

5047
def exception_handler(self, exc_type, exc_value, exc_traceback):
5148
# don't affect default behaviour.
52-
self.capture_exception(exc_type, exc_value, exc_traceback)
49+
self.capture_exception((exc_type, exc_value, exc_traceback))
5350
self.original_excepthook(exc_type, exc_value, exc_traceback)
5451

5552
def thread_exception_handler(self, args):
56-
self.capture_exception(args.exc_type, args.exc_value, args.exc_traceback)
53+
self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback))
5754

5855
def exception_receiver(self, exc_info, extra_properties):
5956
if "distinct_id" in extra_properties:
@@ -62,39 +59,16 @@ def exception_receiver(self, exc_info, extra_properties):
6259
metadata = None
6360
self.capture_exception(exc_info[0], exc_info[1], exc_info[2], metadata)
6461

65-
def capture_exception(self, exc_type, exc_value, exc_traceback, metadata=None):
62+
def capture_exception(self, exception, metadata=None):
6663
try:
6764
# if hasattr(sys, "ps1"):
6865
# # Disable the excepthook for interactive Python shells
6966
# return
7067

71-
# Format stack trace like sentry
72-
all_exceptions_with_trace = exceptions_from_error_tuple((exc_type, exc_value, exc_traceback))
73-
74-
# Add in-app property to frames in the exceptions
75-
event = handle_in_app(
76-
{
77-
"exception": {
78-
"values": all_exceptions_with_trace,
79-
},
80-
}
81-
)
82-
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
83-
8468
distinct_id = metadata.get("distinct_id") if metadata else DEFAULT_DISTINCT_ID
8569
# Make sure we have a distinct_id if its empty in metadata
8670
distinct_id = distinct_id or DEFAULT_DISTINCT_ID
8771

88-
properties = {
89-
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
90-
"$exception_message": all_exceptions_with_trace_and_in_app[0].get("value"),
91-
"$exception_list": all_exceptions_with_trace_and_in_app,
92-
"$exception_personURL": f"{remove_trailing_slash(self.client.raw_host)}/project/{self.client.api_key}/person/{distinct_id}",
93-
}
94-
95-
# TODO: What distinct id should we attach these server-side exceptions to?
96-
# Any heuristic seems prone to errors - how can we know if exception occurred in the context of a user that captured some other event?
97-
98-
self.client.capture(distinct_id, "$exception", properties=properties)
72+
self.client.capture_exception(exception, distinct_id)
9973
except Exception as e:
10074
self.log.exception(f"Failed to capture exception: {e}")

posthog/test/test_client.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,164 @@ def test_basic_capture_with_project_api_key(self):
8484
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
8585
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
8686

87+
def test_basic_capture_exception(self):
88+
89+
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
90+
client = self.client
91+
exception = Exception("test exception")
92+
client.capture_exception(exception)
93+
94+
self.assertTrue(patch_capture.called)
95+
capture_call = patch_capture.call_args[0]
96+
self.assertEqual(capture_call[0], "python-exceptions")
97+
self.assertEqual(capture_call[1], "$exception")
98+
self.assertEqual(
99+
capture_call[2],
100+
{
101+
"$exception_type": "Exception",
102+
"$exception_message": "test exception",
103+
"$exception_list": [
104+
{
105+
"mechanism": {"type": "generic", "handled": True},
106+
"module": None,
107+
"type": "Exception",
108+
"value": "test exception",
109+
}
110+
],
111+
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/python-exceptions",
112+
},
113+
)
114+
115+
def test_basic_capture_exception_with_distinct_id(self):
116+
117+
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
118+
client = self.client
119+
exception = Exception("test exception")
120+
client.capture_exception(exception, "distinct_id")
121+
122+
self.assertTrue(patch_capture.called)
123+
capture_call = patch_capture.call_args[0]
124+
self.assertEqual(capture_call[0], "distinct_id")
125+
self.assertEqual(capture_call[1], "$exception")
126+
self.assertEqual(
127+
capture_call[2],
128+
{
129+
"$exception_type": "Exception",
130+
"$exception_message": "test exception",
131+
"$exception_list": [
132+
{
133+
"mechanism": {"type": "generic", "handled": True},
134+
"module": None,
135+
"type": "Exception",
136+
"value": "test exception",
137+
}
138+
],
139+
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
140+
},
141+
)
142+
143+
def test_basic_capture_exception_with_correct_host_generation(self):
144+
145+
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
146+
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com")
147+
exception = Exception("test exception")
148+
client.capture_exception(exception, "distinct_id")
149+
150+
self.assertTrue(patch_capture.called)
151+
capture_call = patch_capture.call_args[0]
152+
self.assertEqual(capture_call[0], "distinct_id")
153+
self.assertEqual(capture_call[1], "$exception")
154+
self.assertEqual(
155+
capture_call[2],
156+
{
157+
"$exception_type": "Exception",
158+
"$exception_message": "test exception",
159+
"$exception_list": [
160+
{
161+
"mechanism": {"type": "generic", "handled": True},
162+
"module": None,
163+
"type": "Exception",
164+
"value": "test exception",
165+
}
166+
],
167+
"$exception_personURL": "https://aloha.com/project/random_key/person/distinct_id",
168+
},
169+
)
170+
171+
def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(self):
172+
173+
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
174+
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com")
175+
exception = Exception("test exception")
176+
client.capture_exception(exception, "distinct_id")
177+
178+
self.assertTrue(patch_capture.called)
179+
capture_call = patch_capture.call_args[0]
180+
self.assertEqual(capture_call[0], "distinct_id")
181+
self.assertEqual(capture_call[1], "$exception")
182+
self.assertEqual(
183+
capture_call[2],
184+
{
185+
"$exception_type": "Exception",
186+
"$exception_message": "test exception",
187+
"$exception_list": [
188+
{
189+
"mechanism": {"type": "generic", "handled": True},
190+
"module": None,
191+
"type": "Exception",
192+
"value": "test exception",
193+
}
194+
],
195+
"$exception_personURL": "https://app.posthog.com/project/random_key/person/distinct_id",
196+
},
197+
)
198+
199+
def test_basic_capture_exception_with_no_exception_given(self):
200+
201+
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
202+
client = self.client
203+
try:
204+
raise Exception("test exception")
205+
except Exception:
206+
client.capture_exception()
207+
208+
self.assertTrue(patch_capture.called)
209+
capture_call = patch_capture.call_args[0]
210+
self.assertEqual(capture_call[0], "python-exceptions")
211+
self.assertEqual(capture_call[1], "$exception")
212+
self.assertEqual(capture_call[2]["$exception_type"], "Exception")
213+
self.assertEqual(capture_call[2]["$exception_message"], "test exception")
214+
self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["type"], "generic")
215+
self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["handled"], True)
216+
self.assertEqual(capture_call[2]["$exception_list"][0]["module"], None)
217+
self.assertEqual(capture_call[2]["$exception_list"][0]["type"], "Exception")
218+
self.assertEqual(capture_call[2]["$exception_list"][0]["value"], "test exception")
219+
self.assertEqual(
220+
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["filename"],
221+
"posthog/test/test_client.py",
222+
)
223+
self.assertEqual(
224+
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["function"],
225+
"test_basic_capture_exception_with_no_exception_given",
226+
)
227+
self.assertEqual(
228+
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["module"], "posthog.test.test_client"
229+
)
230+
231+
def test_basic_capture_exception_with_no_exception_happening(self):
232+
233+
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
234+
with self.assertLogs("posthog", level="WARNING") as logs:
235+
236+
client = self.client
237+
client.capture_exception()
238+
239+
self.assertFalse(patch_capture.called)
240+
self.assertEqual(
241+
logs.output[0],
242+
"WARNING:posthog:No exception information available",
243+
)
244+
87245
@mock.patch("posthog.client.decide")
88246
def test_basic_capture_with_feature_flags(self, patch_decide):
89247
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}

posthog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "3.6.3"
1+
VERSION = "3.6.4"
22

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

0 commit comments

Comments
 (0)