insider-python 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- insider/_version.py +1 -1
- insider/client.py +19 -1
- insider/contrib/django/apps.py +2 -1
- insider/contrib/django/middleware.py +10 -109
- insider/integrations/__init__.py +20 -0
- insider/integrations/django/__init__.py +50 -0
- insider/integrations/django/capture.py +43 -0
- insider/integrations/django/drf.py +40 -0
- insider/integrations/django/handler.py +42 -0
- insider/integrations/django/request.py +140 -0
- insider/integrations/django/signals.py +39 -0
- insider/integrations/django/wsgi.py +43 -0
- {insider_python-0.1.2.dist-info → insider_python-0.1.3.dist-info}/METADATA +23 -17
- insider_python-0.1.3.dist-info/RECORD +27 -0
- insider_python-0.1.2.dist-info/RECORD +0 -19
- {insider_python-0.1.2.dist-info → insider_python-0.1.3.dist-info}/WHEEL +0 -0
- {insider_python-0.1.2.dist-info → insider_python-0.1.3.dist-info}/top_level.txt +0 -0
insider/_version.py
CHANGED
insider/client.py
CHANGED
|
@@ -18,7 +18,7 @@ import atexit
|
|
|
18
18
|
import os
|
|
19
19
|
import subprocess
|
|
20
20
|
import threading
|
|
21
|
-
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
21
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union
|
|
22
22
|
|
|
23
23
|
from ._envelope import build_envelope, enforce_size_budget
|
|
24
24
|
from ._version import __version__
|
|
@@ -33,6 +33,16 @@ from .transport import BackgroundTransport
|
|
|
33
33
|
VALID_KINDS = {"error", "perf", "log", "custom"}
|
|
34
34
|
VALID_LEVELS = {"debug", "info", "warning", "error", "fatal"}
|
|
35
35
|
|
|
36
|
+
IntegrationLike = Union[Any, type]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _setup_integrations(integrations: Sequence[IntegrationLike]) -> None:
|
|
40
|
+
for integration in integrations:
|
|
41
|
+
instance = integration() if isinstance(integration, type) else integration
|
|
42
|
+
setup_once = getattr(instance, "setup_once", None)
|
|
43
|
+
if callable(setup_once):
|
|
44
|
+
setup_once()
|
|
45
|
+
|
|
36
46
|
|
|
37
47
|
# ---------------------------------------------------------------------------
|
|
38
48
|
# DSN resolution
|
|
@@ -259,6 +269,8 @@ def _set_active(client: Optional[Client]) -> None:
|
|
|
259
269
|
@safe
|
|
260
270
|
def init(
|
|
261
271
|
dsn: Optional[str] = None,
|
|
272
|
+
*,
|
|
273
|
+
integrations: Optional[Sequence[IntegrationLike]] = None,
|
|
262
274
|
**kwargs: Any,
|
|
263
275
|
) -> Optional[Client]:
|
|
264
276
|
"""
|
|
@@ -268,6 +280,9 @@ def init(
|
|
|
268
280
|
Calling `init` a second time is allowed but logs a warning and
|
|
269
281
|
closes the previous client first. The new client becomes the
|
|
270
282
|
process-global one.
|
|
283
|
+
|
|
284
|
+
Pass framework integrations via `integrations=[...]`. Each integration's
|
|
285
|
+
`setup_once()` runs after the client is active.
|
|
271
286
|
"""
|
|
272
287
|
global _active_client
|
|
273
288
|
raw = _resolve_dsn_string(dsn)
|
|
@@ -280,6 +295,8 @@ def init(
|
|
|
280
295
|
debug(f"invalid DSN: {exc}; entering disabled mode")
|
|
281
296
|
return None
|
|
282
297
|
|
|
298
|
+
integration_list = list(integrations or [])
|
|
299
|
+
|
|
283
300
|
with _init_lock:
|
|
284
301
|
if _active_client is not None:
|
|
285
302
|
debug("re-initializing; closing previous client")
|
|
@@ -290,6 +307,7 @@ def init(
|
|
|
290
307
|
client = Client(parsed, **kwargs)
|
|
291
308
|
_set_active(client)
|
|
292
309
|
|
|
310
|
+
_setup_integrations(integration_list)
|
|
293
311
|
atexit.register(_atexit_close)
|
|
294
312
|
return client
|
|
295
313
|
|
insider/contrib/django/apps.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import Any, Dict
|
|
|
13
13
|
from django.apps import AppConfig
|
|
14
14
|
|
|
15
15
|
from ... import init
|
|
16
|
+
from ...integrations.django import DjangoIntegration
|
|
16
17
|
from ...safety import debug
|
|
17
18
|
|
|
18
19
|
|
|
@@ -57,4 +58,4 @@ class InsiderConfig(AppConfig):
|
|
|
57
58
|
# env-var fallback in `init` still applies if Django's settings
|
|
58
59
|
# didn't define it.
|
|
59
60
|
dsn = kwargs.pop("dsn", None)
|
|
60
|
-
init(dsn, **kwargs)
|
|
61
|
+
init(dsn, integrations=[DjangoIntegration()], **kwargs)
|
|
@@ -4,6 +4,10 @@ auto-capture any unhandled exception that escapes a view.
|
|
|
4
4
|
|
|
5
5
|
The middleware is a no-op when the SDK is in disabled mode.
|
|
6
6
|
|
|
7
|
+
Prefer the Sentry-style integration instead — see
|
|
8
|
+
`insider.integrations.django.DjangoIntegration` and `wsgi.py` init.
|
|
9
|
+
This middleware remains for backward compatibility.
|
|
10
|
+
|
|
7
11
|
What we attach to the scope:
|
|
8
12
|
|
|
9
13
|
- method, path, route (URL name if available), query_string
|
|
@@ -25,31 +29,12 @@ None so Django continues its normal 500 handling.
|
|
|
25
29
|
|
|
26
30
|
from __future__ import annotations
|
|
27
31
|
|
|
28
|
-
from typing import Any, Callable
|
|
32
|
+
from typing import Any, Callable
|
|
29
33
|
|
|
30
|
-
from ... import capture_exception
|
|
31
34
|
from ...client import _client
|
|
32
|
-
from ...
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# Request headers that are safe to forward to the dashboard. We keep an
|
|
36
|
-
# allow-list rather than a deny-list because there are too many possible
|
|
37
|
-
# custom headers to enumerate scary ones. Scrubbing further masks names
|
|
38
|
-
# matching the default deny-list (Authorization, Cookie, etc.) at envelope
|
|
39
|
-
# build time.
|
|
40
|
-
_SAFE_HEADERS = {
|
|
41
|
-
"accept",
|
|
42
|
-
"accept-encoding",
|
|
43
|
-
"accept-language",
|
|
44
|
-
"content-type",
|
|
45
|
-
"content-length",
|
|
46
|
-
"host",
|
|
47
|
-
"referer",
|
|
48
|
-
"user-agent",
|
|
49
|
-
"x-forwarded-for",
|
|
50
|
-
"x-real-ip",
|
|
51
|
-
"x-request-id",
|
|
52
|
-
}
|
|
35
|
+
from ...integrations.django.capture import capture_request_exception
|
|
36
|
+
from ...integrations.django.request import build_request_ctx
|
|
37
|
+
from ...safety import safe
|
|
53
38
|
|
|
54
39
|
|
|
55
40
|
class InsiderMiddleware:
|
|
@@ -67,7 +52,7 @@ class InsiderMiddleware:
|
|
|
67
52
|
if client is None:
|
|
68
53
|
return self.get_response(request)
|
|
69
54
|
|
|
70
|
-
ctx =
|
|
55
|
+
ctx = build_request_ctx(request, client.send_default_pii)
|
|
71
56
|
client.scope.set_request(ctx)
|
|
72
57
|
try:
|
|
73
58
|
return self.get_response(request)
|
|
@@ -76,89 +61,5 @@ class InsiderMiddleware:
|
|
|
76
61
|
|
|
77
62
|
@safe
|
|
78
63
|
def process_exception(self, request: Any, exception: BaseException) -> None:
|
|
79
|
-
|
|
80
|
-
capture_exception(exception)
|
|
81
|
-
return None # let Django render the 500
|
|
82
|
-
|
|
83
|
-
# ------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
@staticmethod
|
|
86
|
-
def _build_request_ctx(request: Any, send_default_pii: bool) -> Dict[str, Any]:
|
|
87
|
-
try:
|
|
88
|
-
method = getattr(request, "method", None)
|
|
89
|
-
path = getattr(request, "path", None)
|
|
90
|
-
query = getattr(request, "META", {}).get("QUERY_STRING") or None
|
|
91
|
-
route = None
|
|
92
|
-
try:
|
|
93
|
-
# ResolverMatch is set after urls resolve; in middleware
|
|
94
|
-
# __call__ before view, it's usually None. We still try.
|
|
95
|
-
match = getattr(request, "resolver_match", None)
|
|
96
|
-
if match is not None:
|
|
97
|
-
route = match.view_name
|
|
98
|
-
except Exception:
|
|
99
|
-
pass
|
|
100
|
-
|
|
101
|
-
headers = _extract_headers(getattr(request, "META", {}))
|
|
102
|
-
|
|
103
|
-
ctx: Dict[str, Any] = {
|
|
104
|
-
"method": method,
|
|
105
|
-
"path": path,
|
|
106
|
-
"query_string": query,
|
|
107
|
-
"route": route,
|
|
108
|
-
"headers": headers,
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if send_default_pii:
|
|
112
|
-
ctx["body"] = _read_body(request)
|
|
113
|
-
user = getattr(request, "user", None)
|
|
114
|
-
user_id = getattr(user, "id", None) if user is not None else None
|
|
115
|
-
if user_id is not None:
|
|
116
|
-
ctx["user"] = {"id": user_id}
|
|
117
|
-
|
|
118
|
-
return ctx
|
|
119
|
-
except Exception as exc:
|
|
120
|
-
debug(f"request ctx build failed: {exc}")
|
|
121
|
-
return {}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _extract_headers(meta: Dict[str, Any]) -> Dict[str, str]:
|
|
125
|
-
"""Convert Django's META dict into a real headers dict, allow-listed."""
|
|
126
|
-
headers: Dict[str, str] = {}
|
|
127
|
-
for key, value in meta.items():
|
|
128
|
-
if not key.startswith("HTTP_") and key not in (
|
|
129
|
-
"CONTENT_TYPE",
|
|
130
|
-
"CONTENT_LENGTH",
|
|
131
|
-
):
|
|
132
|
-
continue
|
|
133
|
-
name = key
|
|
134
|
-
if key.startswith("HTTP_"):
|
|
135
|
-
name = key[len("HTTP_") :]
|
|
136
|
-
name = name.replace("_", "-").lower()
|
|
137
|
-
if name not in _SAFE_HEADERS:
|
|
138
|
-
# We still include unknown headers; the scrubber will mask any
|
|
139
|
-
# whose name is in the deny-list. But we cap obviously huge or
|
|
140
|
-
# weird ones here.
|
|
141
|
-
if len(str(value)) > 4096:
|
|
142
|
-
continue
|
|
143
|
-
try:
|
|
144
|
-
headers[name] = str(value)
|
|
145
|
-
except Exception:
|
|
146
|
-
pass
|
|
147
|
-
return headers
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _read_body(request: Any) -> Optional[str]:
|
|
151
|
-
"""
|
|
152
|
-
Return a string version of the request body, or None.
|
|
153
|
-
We don't consume `request.body` if it hasn't been read yet, to avoid
|
|
154
|
-
breaking downstream views; if it's accessible, we take it.
|
|
155
|
-
"""
|
|
156
|
-
try:
|
|
157
|
-
raw = getattr(request, "body", None)
|
|
158
|
-
if raw is None:
|
|
159
|
-
return None
|
|
160
|
-
if isinstance(raw, bytes):
|
|
161
|
-
return raw.decode("utf-8", errors="replace")
|
|
162
|
-
return str(raw)
|
|
163
|
-
except Exception:
|
|
64
|
+
capture_request_exception(request, exception)
|
|
164
65
|
return None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework integrations for Insider.
|
|
3
|
+
|
|
4
|
+
Each integration exposes a `setup_once()` hook that patches into the host
|
|
5
|
+
framework exactly once per process. Pass instances to `insider.init`:
|
|
6
|
+
|
|
7
|
+
insider.init(dsn=..., integrations=[DjangoIntegration()])
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class Integration(Protocol):
|
|
17
|
+
"""Minimal integration contract."""
|
|
18
|
+
|
|
19
|
+
def setup_once(self) -> None:
|
|
20
|
+
"""Install hooks into the host framework. Idempotent."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sentry-style Django integration.
|
|
3
|
+
|
|
4
|
+
Install with `insider.init(..., integrations=[DjangoIntegration()])` in
|
|
5
|
+
`wsgi.py` / `asgi.py` before `get_wsgi_application()`. No middleware or
|
|
6
|
+
`INSTALLED_APPS` wiring required.
|
|
7
|
+
|
|
8
|
+
Hooks installed (each once per process):
|
|
9
|
+
|
|
10
|
+
- `got_request_exception` → auto-capture unhandled view errors
|
|
11
|
+
- `BaseHandler.get_response` → request context on the SDK scope
|
|
12
|
+
- `WSGIHandler.__call__` → capture catastrophic escapes
|
|
13
|
+
- `APIView.initial` (when DRF is present) → DRF request body access
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
from ...safety import debug, safe
|
|
21
|
+
from . import drf, handler, signals, wsgi
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DjangoIntegration:
|
|
25
|
+
"""Patch Django's request/exception path for automatic error capture."""
|
|
26
|
+
|
|
27
|
+
identifier = "django"
|
|
28
|
+
|
|
29
|
+
_lock = threading.Lock()
|
|
30
|
+
_installed = False
|
|
31
|
+
|
|
32
|
+
@safe
|
|
33
|
+
def setup_once(self) -> None:
|
|
34
|
+
cls = type(self)
|
|
35
|
+
with cls._lock:
|
|
36
|
+
if cls._installed:
|
|
37
|
+
return
|
|
38
|
+
cls._installed = True
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import django # noqa: F401
|
|
42
|
+
except ImportError:
|
|
43
|
+
debug("DjangoIntegration: django is not installed")
|
|
44
|
+
cls._installed = False
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
signals.install()
|
|
48
|
+
handler.install()
|
|
49
|
+
wsgi.install()
|
|
50
|
+
drf.install()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared request-exception capture with de-duplication.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ... import capture_exception
|
|
10
|
+
from ...client import _client
|
|
11
|
+
from ...safety import safe
|
|
12
|
+
from .request import build_request_ctx
|
|
13
|
+
|
|
14
|
+
_CAPTURED_ATTR = "_insider_exception_captured"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@safe
|
|
18
|
+
def capture_request_exception(request: Any, exception: BaseException) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Capture an unhandled request exception once per request.
|
|
21
|
+
|
|
22
|
+
Middleware `process_exception` and Django's `got_request_exception`
|
|
23
|
+
signal can both fire for the same failure; the request flag prevents
|
|
24
|
+
double-beaming.
|
|
25
|
+
"""
|
|
26
|
+
if getattr(request, _CAPTURED_ATTR, False):
|
|
27
|
+
return
|
|
28
|
+
setattr(request, _CAPTURED_ATTR, True)
|
|
29
|
+
|
|
30
|
+
client = _client()
|
|
31
|
+
if client is None:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Scope may already be set by middleware or the get_response patch.
|
|
35
|
+
if client.scope.current_request() is None:
|
|
36
|
+
ctx = build_request_ctx(request, client.send_default_pii)
|
|
37
|
+
client.scope.set_request(ctx)
|
|
38
|
+
try:
|
|
39
|
+
capture_exception(exception)
|
|
40
|
+
finally:
|
|
41
|
+
client.scope.clear_request()
|
|
42
|
+
else:
|
|
43
|
+
capture_exception(exception)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional DRF patch: link DRF Request objects for accurate body reads.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ...safety import debug, safe
|
|
10
|
+
from .request import attach_drf_request_backref
|
|
11
|
+
|
|
12
|
+
_patched = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def install() -> None:
|
|
16
|
+
global _patched
|
|
17
|
+
if _patched:
|
|
18
|
+
return
|
|
19
|
+
try:
|
|
20
|
+
from rest_framework.views import APIView
|
|
21
|
+
except ImportError:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
old_initial = APIView.initial
|
|
25
|
+
|
|
26
|
+
@safe
|
|
27
|
+
def patched_initial(
|
|
28
|
+
self: Any,
|
|
29
|
+
request: Any,
|
|
30
|
+
*args: Any,
|
|
31
|
+
**kwargs: Any,
|
|
32
|
+
) -> Any:
|
|
33
|
+
try:
|
|
34
|
+
attach_drf_request_backref(request._request, request)
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
debug(f"drf initial backref failed: {exc}")
|
|
37
|
+
return old_initial(self, request, *args, **kwargs)
|
|
38
|
+
|
|
39
|
+
APIView.initial = patched_initial # type: ignore[method-assign]
|
|
40
|
+
_patched = True
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Patch `BaseHandler.get_response` to attach request context for the request lifetime.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from ...client import _client
|
|
10
|
+
from ...safety import debug, safe
|
|
11
|
+
from .request import build_request_ctx
|
|
12
|
+
|
|
13
|
+
_patched = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def install() -> None:
|
|
17
|
+
global _patched
|
|
18
|
+
if _patched:
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
from django.core.handlers.base import BaseHandler
|
|
22
|
+
except ImportError:
|
|
23
|
+
debug("django BaseHandler unavailable; skipping get_response patch")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
old_get_response = BaseHandler.get_response
|
|
27
|
+
|
|
28
|
+
@safe
|
|
29
|
+
def patched_get_response(self: Any, request: Any) -> Any:
|
|
30
|
+
client = _client()
|
|
31
|
+
if client is None:
|
|
32
|
+
return old_get_response(self, request)
|
|
33
|
+
|
|
34
|
+
ctx = build_request_ctx(request, client.send_default_pii)
|
|
35
|
+
client.scope.set_request(ctx)
|
|
36
|
+
try:
|
|
37
|
+
return old_get_response(self, request)
|
|
38
|
+
finally:
|
|
39
|
+
client.scope.clear_request()
|
|
40
|
+
|
|
41
|
+
BaseHandler.get_response = patched_get_response # type: ignore[method-assign]
|
|
42
|
+
_patched = True
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build Insider request-context dicts from Django (and DRF) request objects.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import weakref
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ...safety import debug
|
|
11
|
+
|
|
12
|
+
# Request headers that are safe to forward to the dashboard. We keep an
|
|
13
|
+
# allow-list rather than a deny-list because there are too many possible
|
|
14
|
+
# custom headers to enumerate scary ones. Scrubbing further masks names
|
|
15
|
+
# matching the default deny-list (Authorization, Cookie, etc.) at envelope
|
|
16
|
+
# build time.
|
|
17
|
+
_SAFE_HEADERS = {
|
|
18
|
+
"accept",
|
|
19
|
+
"accept-encoding",
|
|
20
|
+
"accept-language",
|
|
21
|
+
"content-type",
|
|
22
|
+
"content-length",
|
|
23
|
+
"host",
|
|
24
|
+
"referer",
|
|
25
|
+
"user-agent",
|
|
26
|
+
"x-forwarded-for",
|
|
27
|
+
"x-real-ip",
|
|
28
|
+
"x-request-id",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_DRF_BACKREF_ATTR = "_insider_drf_request_backref"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def attach_drf_request_backref(django_request: Any, drf_request: Any) -> None:
|
|
35
|
+
"""Link a DRF Request to its wrapped Django request for body reads."""
|
|
36
|
+
try:
|
|
37
|
+
setattr(django_request, _DRF_BACKREF_ATTR, weakref.ref(drf_request))
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
debug(f"drf request backref failed: {exc}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_request_ctx(request: Any, send_default_pii: bool) -> Dict[str, Any]:
|
|
43
|
+
try:
|
|
44
|
+
request = _resolve_drf_request(request)
|
|
45
|
+
|
|
46
|
+
method = getattr(request, "method", None)
|
|
47
|
+
path = getattr(request, "path", None)
|
|
48
|
+
meta = getattr(request, "META", {})
|
|
49
|
+
query = meta.get("QUERY_STRING") or None
|
|
50
|
+
route = None
|
|
51
|
+
try:
|
|
52
|
+
match = getattr(request, "resolver_match", None)
|
|
53
|
+
if match is not None:
|
|
54
|
+
route = match.view_name
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
headers = _extract_headers(meta)
|
|
59
|
+
|
|
60
|
+
ctx: Dict[str, Any] = {
|
|
61
|
+
"method": method,
|
|
62
|
+
"path": path,
|
|
63
|
+
"query_string": query,
|
|
64
|
+
"route": route,
|
|
65
|
+
"headers": headers,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if send_default_pii:
|
|
69
|
+
ctx["body"] = _read_body(request)
|
|
70
|
+
user = getattr(request, "user", None)
|
|
71
|
+
user_id = getattr(user, "id", None) if user is not None else None
|
|
72
|
+
if user_id is not None:
|
|
73
|
+
ctx["user"] = {"id": user_id}
|
|
74
|
+
|
|
75
|
+
return ctx
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
debug(f"request ctx build failed: {exc}")
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_drf_request(request: Any) -> Any:
|
|
82
|
+
"""Prefer the DRF Request when a weak backref was attached in `initial`."""
|
|
83
|
+
try:
|
|
84
|
+
backref = getattr(request, _DRF_BACKREF_ATTR, None)
|
|
85
|
+
if backref is not None:
|
|
86
|
+
drf_request = backref()
|
|
87
|
+
if drf_request is not None:
|
|
88
|
+
return drf_request
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return request
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_headers(meta: Dict[str, Any]) -> Dict[str, str]:
|
|
95
|
+
"""Convert Django's META dict into a real headers dict, allow-listed."""
|
|
96
|
+
headers: Dict[str, str] = {}
|
|
97
|
+
for key, value in meta.items():
|
|
98
|
+
if not key.startswith("HTTP_") and key not in (
|
|
99
|
+
"CONTENT_TYPE",
|
|
100
|
+
"CONTENT_LENGTH",
|
|
101
|
+
):
|
|
102
|
+
continue
|
|
103
|
+
name = key
|
|
104
|
+
if key.startswith("HTTP_"):
|
|
105
|
+
name = key[len("HTTP_") :]
|
|
106
|
+
name = name.replace("_", "-").lower()
|
|
107
|
+
if name not in _SAFE_HEADERS:
|
|
108
|
+
if len(str(value)) > 4096:
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
headers[name] = str(value)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
return headers
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_body(request: Any) -> Optional[str]:
|
|
118
|
+
"""
|
|
119
|
+
Return a string version of the request body, or None.
|
|
120
|
+
We don't consume `request.body` if it hasn't been read yet, to avoid
|
|
121
|
+
breaking downstream views; if it's accessible, we take it.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
data = getattr(request, "data", None)
|
|
125
|
+
if data is not None and not isinstance(data, (str, bytes)):
|
|
126
|
+
try:
|
|
127
|
+
import json
|
|
128
|
+
|
|
129
|
+
return json.dumps(data)
|
|
130
|
+
except Exception:
|
|
131
|
+
return str(data)
|
|
132
|
+
|
|
133
|
+
raw = getattr(request, "body", None)
|
|
134
|
+
if raw is None:
|
|
135
|
+
return None
|
|
136
|
+
if isinstance(raw, bytes):
|
|
137
|
+
return raw.decode("utf-8", errors="replace")
|
|
138
|
+
return str(raw)
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connect Django's `got_request_exception` signal to Insider capture.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ...safety import debug, safe
|
|
11
|
+
from .capture import capture_request_exception
|
|
12
|
+
|
|
13
|
+
_connected = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def install() -> None:
|
|
17
|
+
global _connected
|
|
18
|
+
if _connected:
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
from django.core import signals
|
|
22
|
+
except ImportError:
|
|
23
|
+
debug("django signals unavailable; skipping got_request_exception hook")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
signals.got_request_exception.connect(_on_got_request_exception)
|
|
27
|
+
_connected = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@safe
|
|
31
|
+
def _on_got_request_exception(
|
|
32
|
+
sender: Any,
|
|
33
|
+
request: Any,
|
|
34
|
+
**kwargs: Any,
|
|
35
|
+
) -> None:
|
|
36
|
+
exc = sys.exc_info()[1]
|
|
37
|
+
if exc is None:
|
|
38
|
+
return
|
|
39
|
+
capture_request_exception(request, exc)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Patch `WSGIHandler.__call__` to capture exceptions that escape Django entirely.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from ... import capture_exception
|
|
10
|
+
from ...client import _client
|
|
11
|
+
from ...safety import debug, safe
|
|
12
|
+
|
|
13
|
+
_patched = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def install() -> None:
|
|
17
|
+
global _patched
|
|
18
|
+
if _patched:
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
from django.core.handlers.wsgi import WSGIHandler
|
|
22
|
+
except ImportError:
|
|
23
|
+
debug("django WSGIHandler unavailable; skipping WSGI patch")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
old_call: Callable[..., Any] = WSGIHandler.__call__
|
|
27
|
+
|
|
28
|
+
@safe
|
|
29
|
+
def patched_call(
|
|
30
|
+
self: Any,
|
|
31
|
+
environ: Any,
|
|
32
|
+
start_response: Any,
|
|
33
|
+
) -> Any:
|
|
34
|
+
if _client() is None:
|
|
35
|
+
return old_call(self, environ, start_response)
|
|
36
|
+
try:
|
|
37
|
+
return old_call(self, environ, start_response)
|
|
38
|
+
except BaseException as exc:
|
|
39
|
+
capture_exception(exc)
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
WSGIHandler.__call__ = patched_call # type: ignore[method-assign]
|
|
43
|
+
_patched = True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: insider-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Python SDK for Insider — ship Beacons to your Insider server.
|
|
5
5
|
Author: Insider
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,6 +24,7 @@ Provides-Extra: dev
|
|
|
24
24
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
25
|
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
26
26
|
Requires-Dist: django>=4.2; extra == "dev"
|
|
27
|
+
Requires-Dist: djangorestframework>=3.14; extra == "dev"
|
|
27
28
|
|
|
28
29
|
# insider-python
|
|
29
30
|
|
|
@@ -68,26 +69,31 @@ insider.capture_message("cache miss spiked", level="warning")
|
|
|
68
69
|
|
|
69
70
|
### Django
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
|
|
72
73
|
|
|
73
74
|
```python
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
import os
|
|
76
|
+
import insider
|
|
77
|
+
from insider.integrations.django import DjangoIntegration
|
|
78
|
+
|
|
79
|
+
insider.init(
|
|
80
|
+
dsn=os.environ.get("INSIDER_DSN"),
|
|
81
|
+
environment="production",
|
|
82
|
+
release="1.2.3",
|
|
83
|
+
integrations=[DjangoIntegration()],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
from django.core.wsgi import get_wsgi_application
|
|
87
|
+
application = get_wsgi_application()
|
|
87
88
|
```
|
|
88
89
|
|
|
89
|
-
That's the whole setup. Every unhandled exception in a view
|
|
90
|
-
Beacon in your dashboard.
|
|
90
|
+
That's the whole setup. Every unhandled exception in a view — including
|
|
91
|
+
Django REST Framework API views — is now a Beacon in your dashboard.
|
|
92
|
+
No middleware, no `INSTALLED_APPS`, and no `EXCEPTION_HANDLER` wiring.
|
|
93
|
+
|
|
94
|
+
**Legacy setup** (still supported): add `insider.contrib.django` to
|
|
95
|
+
`INSTALLED_APPS` and `InsiderMiddleware` to `MIDDLEWARE`. Prefer the
|
|
96
|
+
`wsgi.py` pattern for new projects.
|
|
91
97
|
|
|
92
98
|
## Configuration
|
|
93
99
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
insider/__init__.py,sha256=DgDevX2wcy9lonZiHaq5dJLieD3_fp3p6l3lotuTpGE,781
|
|
2
|
+
insider/_envelope.py,sha256=mZiNl-Cha4piAhbIgFuU_KOZnaK4GMrYquFJxLn0D5Q,6684
|
|
3
|
+
insider/_version.py,sha256=tSvSo1IuInvJvdgaCfYY3AwA4nRKslqnuXXy0IEtaZE,246
|
|
4
|
+
insider/client.py,sha256=MkwlGp6iIMNsoHtg9pDWX1OqxetC27rmODh5y5qQtyI,12196
|
|
5
|
+
insider/dsn.py,sha256=S8BbRhmKKKU1N6W8cpMSQoyDO_EuUJ1PkcjhGrrf5hw,3009
|
|
6
|
+
insider/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
insider/safety.py,sha256=mtzzWLujuvmd24W2VtVcMGO_81GPBbB2tYuVqU2KsuI,1910
|
|
8
|
+
insider/scope.py,sha256=F1eMWt6uu14uBdncc5DC6Lh735EPcUv1yF4prBi--pc,1708
|
|
9
|
+
insider/scrubbing.py,sha256=U8N3MS5VtDUNpIWN4PZ8bvrPqmjRRXq5hWGXxzoF5Gw,2853
|
|
10
|
+
insider/stacktrace.py,sha256=tgdS9dQ-bVyz59_874BstuCYaP6rGn2k6HMo9sW8Ks8,5310
|
|
11
|
+
insider/transport.py,sha256=8ncFb3C0LgF2hMRoj_fhxj49NIUD6hsVAHfPZKtNcn4,7292
|
|
12
|
+
insider/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
insider/contrib/django/__init__.py,sha256=UMGrJgVO-FqszZo9Cviw7oWS8PLdXVHt3hwXZ_YMj8A,680
|
|
14
|
+
insider/contrib/django/apps.py,sha256=Ct584Pa-S9I82omP3t_Xsn4NrdlqfoNf0eUN9xFa7Hs,2036
|
|
15
|
+
insider/contrib/django/middleware.py,sha256=lX5sGDZw4rJ6gsNEcOpqHspkvzqilxTlUxOszQxwzpI,2077
|
|
16
|
+
insider/integrations/__init__.py,sha256=MrpszNOGT5-2b2310wS3GDjIQWQ7R_-S-OYIMZ5iu1E,526
|
|
17
|
+
insider/integrations/django/__init__.py,sha256=EIUHPMhBSw4pXWaXiteYh5lzThYEzE9gAZBfDypnu4M,1325
|
|
18
|
+
insider/integrations/django/capture.py,sha256=UQfOdHvbD2mhHaTntQq-mXMDpho199V7j6BR6k6ppWY,1182
|
|
19
|
+
insider/integrations/django/drf.py,sha256=MXY4erP9gPBiEvf8Hi0CLrUBczXY1o7Y5wVCjSsQeCY,899
|
|
20
|
+
insider/integrations/django/handler.py,sha256=V2lUvkqWBtechnd2Kj6wToxmWZqpbG7E8BKOxYap-fc,1103
|
|
21
|
+
insider/integrations/django/request.py,sha256=pOuQxnFWeA3KPAc0eVaRtkxGRyr2PetGKEg7OlTnqF4,4181
|
|
22
|
+
insider/integrations/django/signals.py,sha256=kwH7npDG3AC9MILB9f4YUYofy5iwOosapGkBm_3dZY4,802
|
|
23
|
+
insider/integrations/django/wsgi.py,sha256=kJ5qo5LUdRYJtp4NkiD38DZzbb__idJU6FAltYhgtus,1044
|
|
24
|
+
insider_python-0.1.3.dist-info/METADATA,sha256=TL3DKG0aJQXFcKFZJi1Ri6F3BrDaTATEDECgWCtt0t8,3971
|
|
25
|
+
insider_python-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
26
|
+
insider_python-0.1.3.dist-info/top_level.txt,sha256=g_YKp2jCaaefmasZ2nOa9capm0X8q2sAWI_eEClKIos,8
|
|
27
|
+
insider_python-0.1.3.dist-info/RECORD,,
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
insider/__init__.py,sha256=DgDevX2wcy9lonZiHaq5dJLieD3_fp3p6l3lotuTpGE,781
|
|
2
|
-
insider/_envelope.py,sha256=mZiNl-Cha4piAhbIgFuU_KOZnaK4GMrYquFJxLn0D5Q,6684
|
|
3
|
-
insider/_version.py,sha256=lxOKSZJbU9hAXhlPRaq8AWb80mXq8wTnJsQhaOYn7ao,246
|
|
4
|
-
insider/client.py,sha256=qLl7iQQ1N4zygXisGxOOyXNkq16UuWozhEHPGK_24Xs,11540
|
|
5
|
-
insider/dsn.py,sha256=S8BbRhmKKKU1N6W8cpMSQoyDO_EuUJ1PkcjhGrrf5hw,3009
|
|
6
|
-
insider/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
insider/safety.py,sha256=mtzzWLujuvmd24W2VtVcMGO_81GPBbB2tYuVqU2KsuI,1910
|
|
8
|
-
insider/scope.py,sha256=F1eMWt6uu14uBdncc5DC6Lh735EPcUv1yF4prBi--pc,1708
|
|
9
|
-
insider/scrubbing.py,sha256=U8N3MS5VtDUNpIWN4PZ8bvrPqmjRRXq5hWGXxzoF5Gw,2853
|
|
10
|
-
insider/stacktrace.py,sha256=tgdS9dQ-bVyz59_874BstuCYaP6rGn2k6HMo9sW8Ks8,5310
|
|
11
|
-
insider/transport.py,sha256=8ncFb3C0LgF2hMRoj_fhxj49NIUD6hsVAHfPZKtNcn4,7292
|
|
12
|
-
insider/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
-
insider/contrib/django/__init__.py,sha256=UMGrJgVO-FqszZo9Cviw7oWS8PLdXVHt3hwXZ_YMj8A,680
|
|
14
|
-
insider/contrib/django/apps.py,sha256=WOKn-fNp_SSPNyt8xXz_fXYVhkyepafAi1uxpWC9_Bo,1947
|
|
15
|
-
insider/contrib/django/middleware.py,sha256=JWnP_p4JBGyKQMswevjWxoUBSE5o4xgLKYOm1Zrb30g,5365
|
|
16
|
-
insider_python-0.1.2.dist-info/METADATA,sha256=ag7XgxFizqFCl4TuPkSlcid8kCp3qvdj6dzyj5Kr0bM,3581
|
|
17
|
-
insider_python-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
-
insider_python-0.1.2.dist-info/top_level.txt,sha256=g_YKp2jCaaefmasZ2nOa9capm0X8q2sAWI_eEClKIos,8
|
|
19
|
-
insider_python-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|