insider-python 0.1.1__tar.gz → 0.1.3__tar.gz
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_python-0.1.1 → insider_python-0.1.3}/PKG-INFO +25 -18
- {insider_python-0.1.1 → insider_python-0.1.3}/README.md +21 -16
- {insider_python-0.1.1 → insider_python-0.1.3}/pyproject.toml +4 -2
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/_version.py +1 -1
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/client.py +19 -1
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/contrib/django/apps.py +2 -1
- insider_python-0.1.3/src/insider/contrib/django/middleware.py +65 -0
- insider_python-0.1.3/src/insider/integrations/__init__.py +20 -0
- insider_python-0.1.3/src/insider/integrations/django/__init__.py +50 -0
- insider_python-0.1.3/src/insider/integrations/django/capture.py +43 -0
- insider_python-0.1.3/src/insider/integrations/django/drf.py +40 -0
- insider_python-0.1.3/src/insider/integrations/django/handler.py +42 -0
- insider_python-0.1.3/src/insider/integrations/django/request.py +140 -0
- insider_python-0.1.3/src/insider/integrations/django/signals.py +39 -0
- insider_python-0.1.3/src/insider/integrations/django/wsgi.py +43 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider_python.egg-info/PKG-INFO +25 -18
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider_python.egg-info/SOURCES.txt +10 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider_python.egg-info/requires.txt +1 -0
- insider_python-0.1.3/tests/test_django_integration.py +58 -0
- insider_python-0.1.3/tests/test_drf_integration.py +59 -0
- insider_python-0.1.1/src/insider/contrib/django/middleware.py +0 -164
- {insider_python-0.1.1 → insider_python-0.1.3}/setup.cfg +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/__init__.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/_envelope.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/contrib/__init__.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/contrib/django/__init__.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/dsn.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/py.typed +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/safety.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/scope.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/scrubbing.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/stacktrace.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider/transport.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider_python.egg-info/dependency_links.txt +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/src/insider_python.egg-info/top_level.txt +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_capture.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_django.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_dsn.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_envelope.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_never_crash.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_safety.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_scrubbing.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_stacktrace.py +0 -0
- {insider_python-0.1.1 → insider_python-0.1.3}/tests/test_transport.py +0 -0
|
@@ -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
|
|
@@ -9,12 +9,13 @@ Keywords: insider,telemetry,errors,monitoring,sdk
|
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.9
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
-
Requires-Python: >=3.
|
|
18
|
+
Requires-Python: >=3.8
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
Requires-Dist: urllib3>=1.26
|
|
20
21
|
Provides-Extra: django
|
|
@@ -23,6 +24,7 @@ Provides-Extra: dev
|
|
|
23
24
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
25
|
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
25
26
|
Requires-Dist: django>=4.2; extra == "dev"
|
|
27
|
+
Requires-Dist: djangorestframework>=3.14; extra == "dev"
|
|
26
28
|
|
|
27
29
|
# insider-python
|
|
28
30
|
|
|
@@ -67,26 +69,31 @@ insider.capture_message("cache miss spiked", level="warning")
|
|
|
67
69
|
|
|
68
70
|
### Django
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
|
|
71
73
|
|
|
72
74
|
```python
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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()
|
|
86
88
|
```
|
|
87
89
|
|
|
88
|
-
That's the whole setup. Every unhandled exception in a view
|
|
89
|
-
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.
|
|
90
97
|
|
|
91
98
|
## Configuration
|
|
92
99
|
|
|
@@ -41,26 +41,31 @@ insider.capture_message("cache miss spiked", level="warning")
|
|
|
41
41
|
|
|
42
42
|
### Django
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
import os
|
|
48
|
+
import insider
|
|
49
|
+
from insider.integrations.django import DjangoIntegration
|
|
50
|
+
|
|
51
|
+
insider.init(
|
|
52
|
+
dsn=os.environ.get("INSIDER_DSN"),
|
|
53
|
+
environment="production",
|
|
54
|
+
release="1.2.3",
|
|
55
|
+
integrations=[DjangoIntegration()],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
from django.core.wsgi import get_wsgi_application
|
|
59
|
+
application = get_wsgi_application()
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
That's the whole setup. Every unhandled exception in a view
|
|
63
|
-
Beacon in your dashboard.
|
|
62
|
+
That's the whole setup. Every unhandled exception in a view — including
|
|
63
|
+
Django REST Framework API views — is now a Beacon in your dashboard.
|
|
64
|
+
No middleware, no `INSTALLED_APPS`, and no `EXCEPTION_HANDLER` wiring.
|
|
65
|
+
|
|
66
|
+
**Legacy setup** (still supported): add `insider.contrib.django` to
|
|
67
|
+
`INSTALLED_APPS` and `InsiderMiddleware` to `MIDDLEWARE`. Prefer the
|
|
68
|
+
`wsgi.py` pattern for new projects.
|
|
64
69
|
|
|
65
70
|
## Configuration
|
|
66
71
|
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "insider-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Python SDK for Insider — ship Beacons to your Insider server."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [{ name = "Insider" }]
|
|
13
13
|
keywords = ["insider", "telemetry", "errors", "monitoring", "sdk"]
|
|
@@ -15,6 +15,7 @@ classifiers = [
|
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.8",
|
|
18
19
|
"Programming Language :: Python :: 3.9",
|
|
19
20
|
"Programming Language :: Python :: 3.10",
|
|
20
21
|
"Programming Language :: Python :: 3.11",
|
|
@@ -31,6 +32,7 @@ dev = [
|
|
|
31
32
|
"pytest>=8",
|
|
32
33
|
"pytest-django>=4.8",
|
|
33
34
|
"django>=4.2",
|
|
35
|
+
"djangorestframework>=3.14",
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
[project.urls]
|
|
@@ -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
|
|
|
@@ -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)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
InsiderMiddleware: attach request context to the SDK scope, and
|
|
3
|
+
auto-capture any unhandled exception that escapes a view.
|
|
4
|
+
|
|
5
|
+
The middleware is a no-op when the SDK is in disabled mode.
|
|
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
|
+
|
|
11
|
+
What we attach to the scope:
|
|
12
|
+
|
|
13
|
+
- method, path, route (URL name if available), query_string
|
|
14
|
+
- headers (post-scrubbing — note that scrubbing happens at envelope
|
|
15
|
+
build time, not here, so headers go on as-is and get masked just
|
|
16
|
+
before transport)
|
|
17
|
+
- body and user.id ONLY when `send_default_pii=True` in init()
|
|
18
|
+
|
|
19
|
+
What we never touch:
|
|
20
|
+
|
|
21
|
+
- request.session
|
|
22
|
+
- file uploads
|
|
23
|
+
- anything from request.META not on the allowlist
|
|
24
|
+
|
|
25
|
+
`process_exception` is Django's hook for "an exception escaped a view
|
|
26
|
+
without being handled". We call `capture_exception` and then return
|
|
27
|
+
None so Django continues its normal 500 handling.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
from ...client import _client
|
|
35
|
+
from ...integrations.django.capture import capture_request_exception
|
|
36
|
+
from ...integrations.django.request import build_request_ctx
|
|
37
|
+
from ...safety import safe
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InsiderMiddleware:
|
|
41
|
+
"""
|
|
42
|
+
Django middleware. Installs request context on the SDK scope, then
|
|
43
|
+
captures any unhandled exception with that context attached.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
47
|
+
self.get_response = get_response
|
|
48
|
+
|
|
49
|
+
@safe
|
|
50
|
+
def __call__(self, request: Any) -> Any:
|
|
51
|
+
client = _client()
|
|
52
|
+
if client is None:
|
|
53
|
+
return self.get_response(request)
|
|
54
|
+
|
|
55
|
+
ctx = build_request_ctx(request, client.send_default_pii)
|
|
56
|
+
client.scope.set_request(ctx)
|
|
57
|
+
try:
|
|
58
|
+
return self.get_response(request)
|
|
59
|
+
finally:
|
|
60
|
+
client.scope.clear_request()
|
|
61
|
+
|
|
62
|
+
@safe
|
|
63
|
+
def process_exception(self, request: Any, exception: BaseException) -> None:
|
|
64
|
+
capture_request_exception(request, exception)
|
|
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
|
|
@@ -9,12 +9,13 @@ Keywords: insider,telemetry,errors,monitoring,sdk
|
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.9
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
-
Requires-Python: >=3.
|
|
18
|
+
Requires-Python: >=3.8
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
Requires-Dist: urllib3>=1.26
|
|
20
21
|
Provides-Extra: django
|
|
@@ -23,6 +24,7 @@ Provides-Extra: dev
|
|
|
23
24
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
25
|
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
25
26
|
Requires-Dist: django>=4.2; extra == "dev"
|
|
27
|
+
Requires-Dist: djangorestframework>=3.14; extra == "dev"
|
|
26
28
|
|
|
27
29
|
# insider-python
|
|
28
30
|
|
|
@@ -67,26 +69,31 @@ insider.capture_message("cache miss spiked", level="warning")
|
|
|
67
69
|
|
|
68
70
|
### Django
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
|
|
71
73
|
|
|
72
74
|
```python
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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()
|
|
86
88
|
```
|
|
87
89
|
|
|
88
|
-
That's the whole setup. Every unhandled exception in a view
|
|
89
|
-
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.
|
|
90
97
|
|
|
91
98
|
## Configuration
|
|
92
99
|
|
|
@@ -15,6 +15,14 @@ src/insider/contrib/__init__.py
|
|
|
15
15
|
src/insider/contrib/django/__init__.py
|
|
16
16
|
src/insider/contrib/django/apps.py
|
|
17
17
|
src/insider/contrib/django/middleware.py
|
|
18
|
+
src/insider/integrations/__init__.py
|
|
19
|
+
src/insider/integrations/django/__init__.py
|
|
20
|
+
src/insider/integrations/django/capture.py
|
|
21
|
+
src/insider/integrations/django/drf.py
|
|
22
|
+
src/insider/integrations/django/handler.py
|
|
23
|
+
src/insider/integrations/django/request.py
|
|
24
|
+
src/insider/integrations/django/signals.py
|
|
25
|
+
src/insider/integrations/django/wsgi.py
|
|
18
26
|
src/insider_python.egg-info/PKG-INFO
|
|
19
27
|
src/insider_python.egg-info/SOURCES.txt
|
|
20
28
|
src/insider_python.egg-info/dependency_links.txt
|
|
@@ -22,6 +30,8 @@ src/insider_python.egg-info/requires.txt
|
|
|
22
30
|
src/insider_python.egg-info/top_level.txt
|
|
23
31
|
tests/test_capture.py
|
|
24
32
|
tests/test_django.py
|
|
33
|
+
tests/test_django_integration.py
|
|
34
|
+
tests/test_drf_integration.py
|
|
25
35
|
tests/test_dsn.py
|
|
26
36
|
tests/test_envelope.py
|
|
27
37
|
tests/test_never_crash.py
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DjangoIntegration tests — Sentry-style hooks without middleware.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from insider.client import _set_active
|
|
10
|
+
from insider.integrations.django import DjangoIntegration
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _integration_env(settings, sdk_client):
|
|
15
|
+
settings.INSTALLED_APPS = [
|
|
16
|
+
"django.contrib.contenttypes",
|
|
17
|
+
"django.contrib.auth",
|
|
18
|
+
]
|
|
19
|
+
settings.MIDDLEWARE = []
|
|
20
|
+
settings.ROOT_URLCONF = "tests.django_integration_urls"
|
|
21
|
+
DjangoIntegration().setup_once()
|
|
22
|
+
yield
|
|
23
|
+
_set_active(None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(autouse=True)
|
|
27
|
+
def _no_raise_on_500(client):
|
|
28
|
+
client.raise_request_exception = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.django_db
|
|
32
|
+
def test_integration_captures_view_exception(client, fake_transport):
|
|
33
|
+
response = client.get("/boom/?foo=bar", HTTP_USER_AGENT="pytest-ua")
|
|
34
|
+
assert response.status_code == 500
|
|
35
|
+
assert len(fake_transport.envelopes) == 1
|
|
36
|
+
env = fake_transport.envelopes[0]
|
|
37
|
+
assert env["kind"] == "error"
|
|
38
|
+
assert env["message"] == "intentional explosion"
|
|
39
|
+
request_ctx = env["payload"]["request"]
|
|
40
|
+
assert request_ctx["method"] == "GET"
|
|
41
|
+
assert request_ctx["path"] == "/boom/"
|
|
42
|
+
assert request_ctx["query_string"] == "foo=bar"
|
|
43
|
+
assert request_ctx["headers"]["user-agent"] == "pytest-ua"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.django_db
|
|
47
|
+
def test_integration_clean_request_does_not_capture(client, fake_transport):
|
|
48
|
+
response = client.get("/ok/")
|
|
49
|
+
assert response.status_code == 200
|
|
50
|
+
assert fake_transport.envelopes == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.django_db
|
|
54
|
+
def test_integration_disabled_mode_is_noop(client, fake_transport):
|
|
55
|
+
_set_active(None)
|
|
56
|
+
response = client.get("/boom/")
|
|
57
|
+
assert response.status_code == 500
|
|
58
|
+
assert fake_transport.envelopes == []
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DRF tests for DjangoIntegration — no middleware, no EXCEPTION_HANDLER wiring.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from insider.client import _set_active
|
|
10
|
+
from insider.integrations.django import DjangoIntegration
|
|
11
|
+
|
|
12
|
+
drf = pytest.importorskip("rest_framework")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def _drf_integration_env(settings, sdk_client):
|
|
17
|
+
settings.INSTALLED_APPS = [
|
|
18
|
+
"django.contrib.contenttypes",
|
|
19
|
+
"django.contrib.auth",
|
|
20
|
+
"rest_framework",
|
|
21
|
+
]
|
|
22
|
+
settings.MIDDLEWARE = []
|
|
23
|
+
settings.ROOT_URLCONF = "tests.django_drf_urls"
|
|
24
|
+
DjangoIntegration().setup_once()
|
|
25
|
+
yield
|
|
26
|
+
_set_active(None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(autouse=True)
|
|
30
|
+
def _no_raise_on_500(client):
|
|
31
|
+
client.raise_request_exception = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.django_db
|
|
35
|
+
def test_drf_unhandled_exception_is_captured(client, fake_transport):
|
|
36
|
+
response = client.get("/api/boom/", HTTP_USER_AGENT="pytest-drf")
|
|
37
|
+
assert response.status_code == 500
|
|
38
|
+
assert len(fake_transport.envelopes) == 1
|
|
39
|
+
env = fake_transport.envelopes[0]
|
|
40
|
+
assert env["kind"] == "error"
|
|
41
|
+
assert env["message"] == "drf intentional explosion"
|
|
42
|
+
request_ctx = env["payload"]["request"]
|
|
43
|
+
assert request_ctx["method"] == "GET"
|
|
44
|
+
assert request_ctx["path"] == "/api/boom/"
|
|
45
|
+
assert request_ctx["headers"]["user-agent"] == "pytest-drf"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.django_db
|
|
49
|
+
def test_drf_handled_api_exception_is_not_captured(client, fake_transport):
|
|
50
|
+
response = client.get("/api/bad-request/")
|
|
51
|
+
assert response.status_code == 400
|
|
52
|
+
assert fake_transport.envelopes == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.django_db
|
|
56
|
+
def test_drf_clean_request_does_not_capture(client, fake_transport):
|
|
57
|
+
response = client.get("/api/ok/")
|
|
58
|
+
assert response.status_code == 200
|
|
59
|
+
assert fake_transport.envelopes == []
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
InsiderMiddleware: attach request context to the SDK scope, and
|
|
3
|
-
auto-capture any unhandled exception that escapes a view.
|
|
4
|
-
|
|
5
|
-
The middleware is a no-op when the SDK is in disabled mode.
|
|
6
|
-
|
|
7
|
-
What we attach to the scope:
|
|
8
|
-
|
|
9
|
-
- method, path, route (URL name if available), query_string
|
|
10
|
-
- headers (post-scrubbing — note that scrubbing happens at envelope
|
|
11
|
-
build time, not here, so headers go on as-is and get masked just
|
|
12
|
-
before transport)
|
|
13
|
-
- body and user.id ONLY when `send_default_pii=True` in init()
|
|
14
|
-
|
|
15
|
-
What we never touch:
|
|
16
|
-
|
|
17
|
-
- request.session
|
|
18
|
-
- file uploads
|
|
19
|
-
- anything from request.META not on the allowlist
|
|
20
|
-
|
|
21
|
-
`process_exception` is Django's hook for "an exception escaped a view
|
|
22
|
-
without being handled". We call `capture_exception` and then return
|
|
23
|
-
None so Django continues its normal 500 handling.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
from typing import Any, Callable, Dict, Optional
|
|
29
|
-
|
|
30
|
-
from ... import capture_exception
|
|
31
|
-
from ...client import _client
|
|
32
|
-
from ...safety import debug, safe
|
|
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
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class InsiderMiddleware:
|
|
56
|
-
"""
|
|
57
|
-
Django middleware. Installs request context on the SDK scope, then
|
|
58
|
-
captures any unhandled exception with that context attached.
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
62
|
-
self.get_response = get_response
|
|
63
|
-
|
|
64
|
-
@safe
|
|
65
|
-
def __call__(self, request: Any) -> Any:
|
|
66
|
-
client = _client()
|
|
67
|
-
if client is None:
|
|
68
|
-
return self.get_response(request)
|
|
69
|
-
|
|
70
|
-
ctx = self._build_request_ctx(request, client.send_default_pii)
|
|
71
|
-
client.scope.set_request(ctx)
|
|
72
|
-
try:
|
|
73
|
-
return self.get_response(request)
|
|
74
|
-
finally:
|
|
75
|
-
client.scope.clear_request()
|
|
76
|
-
|
|
77
|
-
@safe
|
|
78
|
-
def process_exception(self, request: Any, exception: BaseException) -> None:
|
|
79
|
-
# Scope's already set in __call__; capture inherits the request ctx.
|
|
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:
|
|
164
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{insider_python-0.1.1 → insider_python-0.1.3}/src/insider_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|