Skip to content

Commit ffa35fa

Browse files
authored
feat(errors): Add django integration and in app frames (#131)
1 parent 24b7b91 commit ffa35fa

File tree

11 files changed

+269
-14
lines changed

11 files changed

+269
-14
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.1 - 2024-09-03
2+
3+
1. Adds django integration to exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
4+
15
## 3.6.0 - 2024-08-28
26

37
1. Adds exception autocapture in alpha state. This feature is not yet stable and may change in future versions.

posthog/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import datetime # noqa: F401
2-
from typing import Callable, Dict, Optional, Tuple # noqa: F401
2+
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
56
from posthog.version import VERSION
67

78
__version__ = VERSION
@@ -21,6 +22,7 @@
2122
feature_flags_request_timeout_seconds = 3 # type: int
2223
# Currently alpha, use at your own risk
2324
enable_exception_autocapture = False # type: bool
25+
exception_autocapture_integrations = [] # type: List[Integrations]
2426

2527
default_client = None # type: Optional[Client]
2628

@@ -460,6 +462,7 @@ def _proxy(method, *args, **kwargs):
460462
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
461463
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
462464
enable_exception_autocapture=enable_exception_autocapture,
465+
exception_autocapture_integrations=exception_autocapture_integrations,
463466
)
464467

465468
# always set incase user changes it

posthog/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def __init__(
5353
historical_migration=False,
5454
feature_flags_request_timeout_seconds=3,
5555
enable_exception_autocapture=False,
56+
exception_autocapture_integrations=None,
5657
):
5758
self.queue = queue.Queue(max_queue_size)
5859

@@ -65,6 +66,8 @@ def __init__(
6566
self.debug = debug
6667
self.send = send
6768
self.sync_mode = sync_mode
69+
# Used for session replay URL generation - we don't want the server host here.
70+
self.raw_host = host
6871
self.host = determine_server_host(host)
6972
self.gzip = gzip
7073
self.timeout = timeout
@@ -80,6 +83,8 @@ def __init__(
8083
self.disable_geoip = disable_geoip
8184
self.historical_migration = historical_migration
8285
self.enable_exception_autocapture = enable_exception_autocapture
86+
self.exception_autocapture_integrations = exception_autocapture_integrations
87+
self.exception_capture = None
8388

8489
# personal_api_key: This should be a generated Personal API Key, private
8590
self.personal_api_key = personal_api_key
@@ -92,7 +97,7 @@ def __init__(
9297
self.log.setLevel(logging.WARNING)
9398

9499
if self.enable_exception_autocapture:
95-
self.exception_capture = ExceptionCapture(self)
100+
self.exception_capture = ExceptionCapture(self, integrations=self.exception_autocapture_integrations)
96101

97102
if sync_mode:
98103
self.consumers = None
@@ -432,6 +437,9 @@ def shutdown(self):
432437
self.flush()
433438
self.join()
434439

440+
if self.exception_capture:
441+
self.exception_capture.close()
442+
435443
def _load_feature_flags(self):
436444
try:
437445
response = get(

posthog/exception_capture.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,51 @@
11
import logging
22
import sys
33
import threading
4-
from typing import TYPE_CHECKING
4+
from enum import Enum
5+
from typing import TYPE_CHECKING, List, Optional
56

6-
from posthog.exception_utils import exceptions_from_error_tuple
7+
from posthog.exception_utils import exceptions_from_error_tuple, handle_in_app
8+
from posthog.utils import remove_trailing_slash
79

810
if TYPE_CHECKING:
911
from posthog.client import Client
1012

1113

14+
class Integrations(str, Enum):
15+
Django = "django"
16+
17+
18+
DEFAULT_DISTINCT_ID = "python-exceptions"
19+
20+
1221
class ExceptionCapture:
1322
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
1423

1524
log = logging.getLogger("posthog")
1625

17-
def __init__(self, client: "Client"):
26+
def __init__(self, client: "Client", integrations: Optional[List[Integrations]] = None):
1827
self.client = client
1928
self.original_excepthook = sys.excepthook
2029
sys.excepthook = self.exception_handler
2130
threading.excepthook = self.thread_exception_handler
31+
self.enabled_integrations = []
32+
33+
for integration in integrations or []:
34+
# TODO: Maybe find a better way of enabling integrations
35+
# This is very annoying currently if we had to add any configuration per integration
36+
if integration == Integrations.Django:
37+
try:
38+
from posthog.exception_integrations.django import DjangoIntegration
39+
40+
enabled_integration = DjangoIntegration(self.exception_receiver)
41+
self.enabled_integrations.append(enabled_integration)
42+
except Exception as e:
43+
self.log.exception(f"Failed to enable Django integration: {e}")
44+
45+
def close(self):
46+
sys.excepthook = self.original_excepthook
47+
for integration in self.enabled_integrations:
48+
integration.uninstall()
2249

2350
def exception_handler(self, exc_type, exc_value, exc_traceback):
2451
# don't affect default behaviour.
@@ -28,7 +55,14 @@ def exception_handler(self, exc_type, exc_value, exc_traceback):
2855
def thread_exception_handler(self, args):
2956
self.capture_exception(args.exc_type, args.exc_value, args.exc_traceback)
3057

31-
def capture_exception(self, exc_type, exc_value, exc_traceback):
58+
def exception_receiver(self, exc_info, extra_properties):
59+
if "distinct_id" in extra_properties:
60+
metadata = {"distinct_id": extra_properties["distinct_id"]}
61+
else:
62+
metadata = None
63+
self.capture_exception(exc_info[0], exc_info[1], exc_info[2], metadata)
64+
65+
def capture_exception(self, exc_type, exc_value, exc_traceback, metadata=None):
3266
try:
3367
# if hasattr(sys, "ps1"):
3468
# # Disable the excepthook for interactive Python shells
@@ -37,17 +71,30 @@ def capture_exception(self, exc_type, exc_value, exc_traceback):
3771
# Format stack trace like sentry
3872
all_exceptions_with_trace = exceptions_from_error_tuple((exc_type, exc_value, exc_traceback))
3973

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+
84+
distinct_id = metadata.get("distinct_id") if metadata else DEFAULT_DISTINCT_ID
85+
# Make sure we have a distinct_id if its empty in metadata
86+
distinct_id = distinct_id or DEFAULT_DISTINCT_ID
87+
4088
properties = {
41-
"$exception_type": all_exceptions_with_trace[0].get("type"),
42-
"$exception_message": all_exceptions_with_trace[0].get("value"),
43-
"$exception_list": all_exceptions_with_trace,
44-
# TODO: Can we somehow get distinct_id from context here? Stateless lib makes this much harder? 😅
45-
# '$exception_personURL': f'{self.client.posthog_host}/project/{self.client.token}/person/{self.client.get_distinct_id()}'
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}",
4693
}
4794

4895
# TODO: What distinct id should we attach these server-side exceptions to?
4996
# Any heuristic seems prone to errors - how can we know if exception occurred in the context of a user that captured some other event?
5097

51-
self.client.capture("python-exceptions", "$exception", properties=properties)
98+
self.client.capture(distinct_id, "$exception", properties=properties)
5299
except Exception as e:
53100
self.log.exception(f"Failed to capture exception: {e}")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class IntegrationEnablingError(Exception):
2+
"""
3+
The integration could not be enabled due to a user error like
4+
`django` not being installed for the `DjangoIntegration`.
5+
"""
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import re
2+
import sys
3+
from typing import TYPE_CHECKING
4+
5+
from posthog.exception_integrations import IntegrationEnablingError
6+
7+
try:
8+
from django import VERSION as DJANGO_VERSION
9+
from django.core import signals
10+
11+
except ImportError:
12+
raise IntegrationEnablingError("Django not installed")
13+
14+
15+
if TYPE_CHECKING:
16+
from typing import Any, Dict # noqa: F401
17+
18+
from django.core.handlers.wsgi import WSGIRequest # noqa: F401
19+
20+
21+
class DjangoIntegration:
22+
# TODO: Abstract integrations one we have more and can see patterns
23+
"""
24+
Autocapture errors from a Django application.
25+
"""
26+
27+
identifier = "django"
28+
29+
def __init__(self, capture_exception_fn=None):
30+
31+
if DJANGO_VERSION < (4, 2):
32+
raise IntegrationEnablingError("Django 4.2 or newer is required.")
33+
34+
# TODO: Right now this seems too complicated / overkill for us, but seems like we can automatically plug in middlewares
35+
# which is great for users (they don't need to do this) and everything should just work.
36+
# We should consider this in the future, but for now we can just use the middleware and signals handlers.
37+
# See: https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/integrations/django/__init__.py
38+
39+
self.capture_exception_fn = capture_exception_fn
40+
41+
def _got_request_exception(request=None, **kwargs):
42+
# type: (WSGIRequest, **Any) -> None
43+
44+
extra_props = {}
45+
if request is not None:
46+
# get headers metadata
47+
extra_props = DjangoRequestExtractor(request).extract_person_data()
48+
49+
self.capture_exception_fn(sys.exc_info(), extra_props)
50+
51+
signals.got_request_exception.connect(_got_request_exception)
52+
53+
def uninstall(self):
54+
pass
55+
56+
57+
class DjangoRequestExtractor:
58+
59+
def __init__(self, request):
60+
# type: (Any) -> None
61+
self.request = request
62+
63+
def extract_person_data(self):
64+
headers = self.headers()
65+
66+
# Extract traceparent and tracestate headers
67+
traceparent = headers.get("traceparent")
68+
tracestate = headers.get("tracestate")
69+
70+
# Extract the distinct_id from tracestate
71+
distinct_id = None
72+
if tracestate:
73+
# TODO: Align on the format of the distinct_id in tracestate
74+
# We can't have comma or equals in header values here, so maybe we should base64 encode it?
75+
match = re.search(r"posthog-distinct-id=([^,]+)", tracestate)
76+
if match:
77+
distinct_id = match.group(1)
78+
79+
return {
80+
"distinct_id": distinct_id,
81+
"ip": headers.get("X-Forwarded-For"),
82+
"user_agent": headers.get("User-Agent"),
83+
"traceparent": traceparent,
84+
}
85+
86+
def headers(self):
87+
# type: () -> Dict[str, str]
88+
return dict(self.request.headers)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("django")
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from posthog.exception_integrations.django import DjangoRequestExtractor
2+
3+
DEFAULT_USER_AGENT = (
4+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
5+
)
6+
7+
8+
def mock_request_factory(override_headers):
9+
class Request:
10+
META = {}
11+
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
12+
headers = {
13+
"User-Agent": DEFAULT_USER_AGENT,
14+
"Referrer": "http://example.com",
15+
"X-Forwarded-For": "193.4.5.12",
16+
**(override_headers or {}),
17+
}
18+
19+
return Request()
20+
21+
22+
def test_request_extractor_with_no_trace():
23+
request = mock_request_factory(None)
24+
extractor = DjangoRequestExtractor(request)
25+
assert extractor.extract_person_data() == {
26+
"ip": "193.4.5.12",
27+
"user_agent": DEFAULT_USER_AGENT,
28+
"traceparent": None,
29+
"distinct_id": None,
30+
}
31+
32+
33+
def test_request_extractor_with_trace():
34+
request = mock_request_factory({"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"})
35+
extractor = DjangoRequestExtractor(request)
36+
assert extractor.extract_person_data() == {
37+
"ip": "193.4.5.12",
38+
"user_agent": DEFAULT_USER_AGENT,
39+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
40+
"distinct_id": None,
41+
}
42+
43+
44+
def test_request_extractor_with_tracestate():
45+
request = mock_request_factory(
46+
{
47+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
48+
"tracestate": "posthog-distinct-id=1234",
49+
}
50+
)
51+
extractor = DjangoRequestExtractor(request)
52+
assert extractor.extract_person_data() == {
53+
"ip": "193.4.5.12",
54+
"user_agent": DEFAULT_USER_AGENT,
55+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
56+
"distinct_id": "1234",
57+
}
58+
59+
60+
def test_request_extractor_with_complicated_tracestate():
61+
request = mock_request_factory({"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"})
62+
extractor = DjangoRequestExtractor(request)
63+
assert extractor.extract_person_data() == {
64+
"ip": "193.4.5.12",
65+
"user_agent": DEFAULT_USER_AGENT,
66+
"traceparent": None,
67+
"distinct_id": "alohaMountainsXUYZ",
68+
}

posthog/test/test_exception_capture.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,32 @@ def test_excepthook(tmpdir):
3232
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"filename": "app.py", "abs_path"'
3333
in output
3434
)
35+
36+
37+
def test_trying_to_use_django_integration(tmpdir):
38+
app = tmpdir.join("app.py")
39+
app.write(
40+
dedent(
41+
"""
42+
from posthog import Posthog, Integrations
43+
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
44+
45+
# frame_value = "LOL"
46+
47+
1/0
48+
"""
49+
)
50+
)
51+
52+
with pytest.raises(subprocess.CalledProcessError) as excinfo:
53+
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
54+
55+
output = excinfo.value.output
56+
57+
assert b"ZeroDivisionError" in output
58+
assert b"LOL" in output
59+
assert b"DEBUG:posthog:data uploaded successfully" in output
60+
assert (
61+
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"filename": "app.py", "abs_path"'
62+
in output
63+
)

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.0"
1+
VERSION = "3.6.1"
22

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

0 commit comments

Comments
 (0)