insider-python 0.1.4__py3-none-any.whl → 0.3.0__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/integrations/django/__init__.py +75 -14
- insider/integrations/django/asgi.py +123 -0
- insider/integrations/django/capture.py +14 -0
- insider/integrations/django/handler.py +75 -13
- insider/integrations/django/perf.py +22 -5
- insider/integrations/django/request.py +41 -0
- insider/integrations/django/signals.py +47 -7
- insider/safety.py +26 -0
- {insider_python-0.1.4.dist-info → insider_python-0.3.0.dist-info}/METADATA +66 -2
- {insider_python-0.1.4.dist-info → insider_python-0.3.0.dist-info}/RECORD +13 -12
- {insider_python-0.1.4.dist-info → insider_python-0.3.0.dist-info}/WHEEL +0 -0
- {insider_python-0.1.4.dist-info → insider_python-0.3.0.dist-info}/top_level.txt +0 -0
insider/_version.py
CHANGED
|
@@ -2,24 +2,29 @@
|
|
|
2
2
|
Sentry-style Django integration.
|
|
3
3
|
|
|
4
4
|
Install with `insider.init(..., integrations=[DjangoIntegration()])` in
|
|
5
|
-
`wsgi.py` / `asgi.py` before `get_wsgi_application()
|
|
6
|
-
`INSTALLED_APPS` wiring required.
|
|
5
|
+
`wsgi.py` / `asgi.py` before `get_wsgi_application()` or
|
|
6
|
+
`get_asgi_application()`. No middleware or `INSTALLED_APPS` wiring required.
|
|
7
7
|
|
|
8
8
|
Hooks installed (each once per process):
|
|
9
9
|
|
|
10
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
|
|
11
|
+
- `BaseHandler.get_response` → request context on the SDK scope (WSGI + ASGI HTTP)
|
|
12
|
+
- `WSGIHandler.__call__` → capture catastrophic WSGI escapes
|
|
13
|
+
- `ASGIHandler.__call__` → capture catastrophic ASGI escapes
|
|
13
14
|
- `APIView.initial` (when DRF is present) → DRF request body access
|
|
14
15
|
- Auto `kind=request` beacon per HTTP request (optional, default on)
|
|
16
|
+
|
|
17
|
+
Channels / `ProtocolTypeRouter`: use `wrap_asgi_application()` on the HTTP
|
|
18
|
+
branch with `DjangoIntegration(auto_perf=False)` — see `asgi.py`.
|
|
15
19
|
"""
|
|
16
20
|
|
|
17
21
|
from __future__ import annotations
|
|
18
22
|
|
|
19
23
|
import threading
|
|
24
|
+
from typing import Any, Dict
|
|
20
25
|
|
|
21
|
-
from ...safety import debug
|
|
22
|
-
from . import drf, handler, signals, wsgi
|
|
26
|
+
from ...safety import debug
|
|
27
|
+
from . import asgi, drf, handler, signals, wsgi
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
class DjangoIntegration:
|
|
@@ -34,27 +39,83 @@ class DjangoIntegration:
|
|
|
34
39
|
"""
|
|
35
40
|
Args:
|
|
36
41
|
auto_perf: When True (default), emit one `kind=request` beacon
|
|
37
|
-
after every HTTP request
|
|
38
|
-
|
|
42
|
+
after every HTTP request via the `get_response` patch.
|
|
43
|
+
Set False when using `wrap_asgi_application()` on the HTTP
|
|
44
|
+
branch of a Channels router (avoids double capture).
|
|
39
45
|
"""
|
|
40
46
|
self.auto_perf = auto_perf
|
|
41
47
|
|
|
42
|
-
@safe
|
|
43
48
|
def setup_once(self) -> None:
|
|
44
49
|
cls = type(self)
|
|
45
50
|
with cls._lock:
|
|
46
51
|
if cls._installed:
|
|
47
52
|
return
|
|
48
|
-
cls._installed = True
|
|
49
53
|
|
|
50
54
|
try:
|
|
51
55
|
import django # noqa: F401
|
|
52
56
|
except ImportError:
|
|
53
57
|
debug("DjangoIntegration: django is not installed")
|
|
54
|
-
cls._installed = False
|
|
55
58
|
return
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
# Patch get_response first. Optional hooks below must not block this.
|
|
58
61
|
handler.install(auto_perf=self.auto_perf)
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
if not handler._patched:
|
|
63
|
+
debug(
|
|
64
|
+
"DjangoIntegration: get_response patch failed — "
|
|
65
|
+
"call insider.init() before get_wsgi_application() / "
|
|
66
|
+
"get_asgi_application()"
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
with cls._lock:
|
|
71
|
+
cls._installed = True
|
|
72
|
+
|
|
73
|
+
for label, install in (
|
|
74
|
+
("signals", signals.install),
|
|
75
|
+
("wsgi", wsgi.install),
|
|
76
|
+
("asgi", asgi.install),
|
|
77
|
+
("drf", drf.install),
|
|
78
|
+
):
|
|
79
|
+
try:
|
|
80
|
+
install()
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
debug(f"DjangoIntegration: {label} hook failed: {exc}")
|
|
83
|
+
|
|
84
|
+
_log_integration_status()
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def reset_for_tests(cls) -> None:
|
|
88
|
+
"""Test helper — allow `setup_once()` to run again in the same process."""
|
|
89
|
+
with cls._lock:
|
|
90
|
+
cls._installed = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_integration_status() -> Dict[str, Any]:
|
|
94
|
+
"""Return which Django hooks are active in this process (tests + debug)."""
|
|
95
|
+
return {
|
|
96
|
+
"handler": handler._patched,
|
|
97
|
+
"handler_auto_perf": handler._auto_perf,
|
|
98
|
+
"wsgi": wsgi._patched,
|
|
99
|
+
"asgi_handler": asgi._handler_patched,
|
|
100
|
+
"signals": signals._connected,
|
|
101
|
+
"response_for_exception": signals._rfe_patched,
|
|
102
|
+
"drf": drf._patched,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _log_integration_status() -> None:
|
|
107
|
+
status = get_integration_status()
|
|
108
|
+
debug(
|
|
109
|
+
"DjangoIntegration: "
|
|
110
|
+
+ ", ".join(f"{key}={value}" for key, value in status.items())
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"DjangoIntegration",
|
|
116
|
+
"get_integration_status",
|
|
117
|
+
"wrap_asgi_application",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# Re-export for `from insider.integrations.django.asgi import ...`
|
|
121
|
+
from .asgi import wrap_asgi_application # noqa: E402
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASGI support for Django — escape capture and optional HTTP wrapper.
|
|
3
|
+
|
|
4
|
+
`install()` patches `ASGIHandler` (mirrors `wsgi.install()`).
|
|
5
|
+
|
|
6
|
+
`wrap_asgi_application()` wraps the HTTP branch of a Channels
|
|
7
|
+
`ProtocolTypeRouter` (or plain `get_asgi_application()`). Use with
|
|
8
|
+
`DjangoIntegration(auto_perf=False)` so footprints are not emitted twice.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any, Callable, Dict, MutableMapping, Optional
|
|
16
|
+
|
|
17
|
+
from ... import capture_exception
|
|
18
|
+
from ...client import _client
|
|
19
|
+
from ...safety import debug, safe
|
|
20
|
+
from ...stacktrace import exception_payload
|
|
21
|
+
from .perf import emit_http_footprint
|
|
22
|
+
from .request import build_request_ctx_from_scope
|
|
23
|
+
|
|
24
|
+
ASGIApp = Callable[..., Any]
|
|
25
|
+
|
|
26
|
+
_handler_patched = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install() -> None:
|
|
30
|
+
"""Patch ASGIHandler to capture exceptions that escape Django entirely."""
|
|
31
|
+
global _handler_patched
|
|
32
|
+
if _handler_patched:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
from django.core.handlers.asgi import ASGIHandler
|
|
36
|
+
except ImportError:
|
|
37
|
+
debug("django ASGIHandler unavailable; skipping ASGI patch")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
old_call = ASGIHandler.__call__
|
|
41
|
+
|
|
42
|
+
@safe
|
|
43
|
+
async def patched_call(
|
|
44
|
+
self: Any,
|
|
45
|
+
scope: MutableMapping[str, Any],
|
|
46
|
+
receive: Callable[..., Any],
|
|
47
|
+
send: Callable[..., Any],
|
|
48
|
+
) -> Any:
|
|
49
|
+
if _client() is None:
|
|
50
|
+
return await old_call(self, scope, receive, send)
|
|
51
|
+
try:
|
|
52
|
+
return await old_call(self, scope, receive, send)
|
|
53
|
+
except BaseException as exc:
|
|
54
|
+
capture_exception(exc)
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
ASGIHandler.__call__ = patched_call # type: ignore[method-assign]
|
|
58
|
+
_handler_patched = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def wrap_asgi_application(application: ASGIApp) -> ASGIApp:
|
|
62
|
+
"""
|
|
63
|
+
Wrap a Django (or other) ASGI HTTP app to emit one footprint per request.
|
|
64
|
+
|
|
65
|
+
Pass the return value of `get_asgi_application()` or the ``"http"`` branch
|
|
66
|
+
of a ``ProtocolTypeRouter``. Pair with ``DjangoIntegration(auto_perf=False)``.
|
|
67
|
+
"""
|
|
68
|
+
return _InsiderAsgiHttpWrapper(application)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _InsiderAsgiHttpWrapper:
|
|
72
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
73
|
+
self.app = app
|
|
74
|
+
|
|
75
|
+
async def __call__(
|
|
76
|
+
self,
|
|
77
|
+
scope: MutableMapping[str, Any],
|
|
78
|
+
receive: Callable[..., Any],
|
|
79
|
+
send: Callable[..., Any],
|
|
80
|
+
) -> None:
|
|
81
|
+
if scope.get("type") != "http":
|
|
82
|
+
await self.app(scope, receive, send)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
client = _client()
|
|
86
|
+
if client is None:
|
|
87
|
+
await self.app(scope, receive, send)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
trace_id = uuid.uuid4().hex
|
|
91
|
+
client.scope.set_trace_id(trace_id)
|
|
92
|
+
client.scope.set_request(
|
|
93
|
+
build_request_ctx_from_scope(scope, client.send_default_pii)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
start = time.perf_counter()
|
|
97
|
+
status_code: Optional[int] = None
|
|
98
|
+
|
|
99
|
+
async def send_wrapper(message: Dict[str, Any]) -> None:
|
|
100
|
+
nonlocal status_code
|
|
101
|
+
if message.get("type") == "http.response.start":
|
|
102
|
+
status_code = int(message.get("status", 500))
|
|
103
|
+
await send(message)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
await self.app(scope, receive, send_wrapper)
|
|
107
|
+
except BaseException as exc:
|
|
108
|
+
block = exception_payload(
|
|
109
|
+
exc, in_app_include=client.scope.static.in_app_include
|
|
110
|
+
)
|
|
111
|
+
client.scope.set_pending_exception(block)
|
|
112
|
+
raise
|
|
113
|
+
finally:
|
|
114
|
+
path = str(scope.get("path") or "/")
|
|
115
|
+
method = str(scope.get("method") or "GET")
|
|
116
|
+
emit_http_footprint(
|
|
117
|
+
path=path,
|
|
118
|
+
method=method,
|
|
119
|
+
duration_ms=(time.perf_counter() - start) * 1000.0,
|
|
120
|
+
status_code=status_code,
|
|
121
|
+
trace_id=trace_id,
|
|
122
|
+
)
|
|
123
|
+
client.scope.clear_request_cycle()
|
|
@@ -16,6 +16,19 @@ from ...stacktrace import exception_payload
|
|
|
16
16
|
from .request import build_request_ctx
|
|
17
17
|
|
|
18
18
|
_CAPTURED_ATTR = "_insider_exception_captured"
|
|
19
|
+
_PENDING_BLOCK_ATTR = "_insider_pending_exception_block"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sync_pending_from_request(request: Any) -> None:
|
|
23
|
+
"""Copy exception buffered on the request (async worker thread) onto scope."""
|
|
24
|
+
client = _client()
|
|
25
|
+
if client is None:
|
|
26
|
+
return
|
|
27
|
+
if client.scope.current_pending_exception() is not None:
|
|
28
|
+
return
|
|
29
|
+
block = getattr(request, _PENDING_BLOCK_ATTR, None)
|
|
30
|
+
if block is not None:
|
|
31
|
+
client.scope.set_pending_exception(block)
|
|
19
32
|
|
|
20
33
|
|
|
21
34
|
@safe
|
|
@@ -42,4 +55,5 @@ def capture_request_exception(request: Any, exception: BaseException) -> None:
|
|
|
42
55
|
block = exception_payload(
|
|
43
56
|
exception, in_app_include=client.scope.static.in_app_include
|
|
44
57
|
)
|
|
58
|
+
setattr(request, _PENDING_BLOCK_ATTR, block)
|
|
45
59
|
client.scope.set_pending_exception(block)
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Patch `BaseHandler.get_response` for request context
|
|
2
|
+
Patch `BaseHandler.get_response` / `get_response_async` for request context
|
|
3
|
+
and auto perf timing.
|
|
3
4
|
|
|
4
|
-
We patch
|
|
5
|
+
We patch the handler (not middleware) because:
|
|
5
6
|
|
|
6
|
-
- Customers already init in `wsgi.py` without touching INSTALLED_APPS.
|
|
7
|
+
- Customers already init in `wsgi.py` / `asgi.py` without touching INSTALLED_APPS.
|
|
7
8
|
- The patch wraps the entire handler, including middleware inside Django's
|
|
8
9
|
request cycle.
|
|
9
10
|
- Perf timing in `finally` runs once per request whether the view returns
|
|
10
11
|
200 or raises (converted to 500 by Django).
|
|
12
|
+
|
|
13
|
+
Django 4.1+ ASGI uses `get_response_async` for the async request path
|
|
14
|
+
(Daphne, `AsyncClient`). Both sync and async entrypoints are patched.
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
from __future__ import annotations
|
|
@@ -19,12 +23,39 @@ from typing import Any, Callable
|
|
|
19
23
|
from ...client import _client
|
|
20
24
|
from ...safety import debug, safe
|
|
21
25
|
from .perf import emit_request_envelope
|
|
22
|
-
from .
|
|
26
|
+
from .capture import sync_pending_from_request
|
|
23
27
|
|
|
24
28
|
_patched = False
|
|
25
29
|
_auto_perf = True
|
|
26
30
|
|
|
27
31
|
|
|
32
|
+
def _finalize_request_cycle(
|
|
33
|
+
request: Any,
|
|
34
|
+
*,
|
|
35
|
+
start: float,
|
|
36
|
+
response: Any,
|
|
37
|
+
status_code: int | None,
|
|
38
|
+
trace_id: str,
|
|
39
|
+
) -> None:
|
|
40
|
+
client = _client()
|
|
41
|
+
if client is None:
|
|
42
|
+
return
|
|
43
|
+
duration_ms = (time.perf_counter() - start) * 1000.0
|
|
44
|
+
if status_code is None and response is not None:
|
|
45
|
+
status_code = getattr(response, "status_code", None)
|
|
46
|
+
sync_pending_from_request(request)
|
|
47
|
+
if _auto_perf:
|
|
48
|
+
emit_request_envelope(
|
|
49
|
+
request,
|
|
50
|
+
duration_ms=duration_ms,
|
|
51
|
+
status_code=status_code,
|
|
52
|
+
trace_id=trace_id,
|
|
53
|
+
)
|
|
54
|
+
client.scope.clear_request_cycle()
|
|
55
|
+
# When auto_perf=False (e.g. wrap_asgi_application), leave scope intact
|
|
56
|
+
# for the outer ASGI wrapper to emit one combined footprint.
|
|
57
|
+
|
|
58
|
+
|
|
28
59
|
def install(*, auto_perf: bool = True) -> None:
|
|
29
60
|
global _patched, _auto_perf
|
|
30
61
|
_auto_perf = auto_perf
|
|
@@ -37,6 +68,7 @@ def install(*, auto_perf: bool = True) -> None:
|
|
|
37
68
|
return
|
|
38
69
|
|
|
39
70
|
old_get_response = BaseHandler.get_response
|
|
71
|
+
old_get_response_async = BaseHandler.get_response_async
|
|
40
72
|
|
|
41
73
|
@safe
|
|
42
74
|
def patched_get_response(self: Any, request: Any) -> Any:
|
|
@@ -46,6 +78,8 @@ def install(*, auto_perf: bool = True) -> None:
|
|
|
46
78
|
|
|
47
79
|
trace_id = uuid.uuid4().hex
|
|
48
80
|
client.scope.set_trace_id(trace_id)
|
|
81
|
+
from .request import build_request_ctx
|
|
82
|
+
|
|
49
83
|
ctx = build_request_ctx(request, client.send_default_pii)
|
|
50
84
|
client.scope.set_request(ctx)
|
|
51
85
|
|
|
@@ -57,15 +91,43 @@ def install(*, auto_perf: bool = True) -> None:
|
|
|
57
91
|
status_code = getattr(response, "status_code", None)
|
|
58
92
|
return response
|
|
59
93
|
finally:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
_finalize_request_cycle(
|
|
95
|
+
request,
|
|
96
|
+
start=start,
|
|
97
|
+
response=response,
|
|
98
|
+
status_code=status_code,
|
|
99
|
+
trace_id=trace_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@safe
|
|
103
|
+
async def patched_get_response_async(self: Any, request: Any) -> Any:
|
|
104
|
+
client = _client()
|
|
105
|
+
if client is None:
|
|
106
|
+
return await old_get_response_async(self, request)
|
|
107
|
+
|
|
108
|
+
trace_id = uuid.uuid4().hex
|
|
109
|
+
client.scope.set_trace_id(trace_id)
|
|
110
|
+
from .request import build_request_ctx
|
|
111
|
+
|
|
112
|
+
ctx = build_request_ctx(request, client.send_default_pii)
|
|
113
|
+
client.scope.set_request(ctx)
|
|
114
|
+
|
|
115
|
+
start = time.perf_counter()
|
|
116
|
+
response = None
|
|
117
|
+
status_code: int | None = None
|
|
118
|
+
try:
|
|
119
|
+
response = await old_get_response_async(self, request)
|
|
120
|
+
status_code = getattr(response, "status_code", None)
|
|
121
|
+
return response
|
|
122
|
+
finally:
|
|
123
|
+
_finalize_request_cycle(
|
|
124
|
+
request,
|
|
125
|
+
start=start,
|
|
126
|
+
response=response,
|
|
127
|
+
status_code=status_code,
|
|
128
|
+
trace_id=trace_id,
|
|
129
|
+
)
|
|
69
130
|
|
|
70
131
|
BaseHandler.get_response = patched_get_response # type: ignore[method-assign]
|
|
132
|
+
BaseHandler.get_response_async = patched_get_response_async # type: ignore[method-assign]
|
|
71
133
|
_patched = True
|
|
@@ -14,9 +14,10 @@ from ...safety import safe
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
@safe
|
|
17
|
-
def
|
|
18
|
-
request: Any,
|
|
17
|
+
def emit_http_footprint(
|
|
19
18
|
*,
|
|
19
|
+
path: str,
|
|
20
|
+
method: Optional[str],
|
|
20
21
|
duration_ms: float,
|
|
21
22
|
status_code: Optional[int],
|
|
22
23
|
trace_id: Optional[str],
|
|
@@ -26,9 +27,7 @@ def emit_request_envelope(
|
|
|
26
27
|
if client is None:
|
|
27
28
|
return
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
op = getattr(request, "path", None) or "unknown"
|
|
31
|
-
|
|
30
|
+
op = path or "unknown"
|
|
32
31
|
client.capture_request(
|
|
33
32
|
duration_ms=duration_ms,
|
|
34
33
|
op=op,
|
|
@@ -36,3 +35,21 @@ def emit_request_envelope(
|
|
|
36
35
|
method=method,
|
|
37
36
|
trace_id=trace_id,
|
|
38
37
|
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@safe
|
|
41
|
+
def emit_request_envelope(
|
|
42
|
+
request: Any,
|
|
43
|
+
*,
|
|
44
|
+
duration_ms: float,
|
|
45
|
+
status_code: Optional[int],
|
|
46
|
+
trace_id: Optional[str],
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Build and ship a single `kind=request` beacon from a Django request."""
|
|
49
|
+
emit_http_footprint(
|
|
50
|
+
path=getattr(request, "path", None) or "unknown",
|
|
51
|
+
method=getattr(request, "method", None),
|
|
52
|
+
duration_ms=duration_ms,
|
|
53
|
+
status_code=status_code,
|
|
54
|
+
trace_id=trace_id,
|
|
55
|
+
)
|
|
@@ -39,6 +39,47 @@ def attach_drf_request_backref(django_request: Any, drf_request: Any) -> None:
|
|
|
39
39
|
debug(f"drf request backref failed: {exc}")
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def build_request_ctx_from_scope(
|
|
43
|
+
scope: Any,
|
|
44
|
+
send_default_pii: bool,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
"""Build request context from a raw ASGI HTTP scope."""
|
|
47
|
+
try:
|
|
48
|
+
headers: Dict[str, str] = {}
|
|
49
|
+
for key, value in scope.get("headers") or []:
|
|
50
|
+
try:
|
|
51
|
+
name = key.decode("latin-1").lower().replace("_", "-")
|
|
52
|
+
val = value.decode("latin-1")
|
|
53
|
+
except Exception:
|
|
54
|
+
continue
|
|
55
|
+
if name not in _SAFE_HEADERS and len(val) > 4096:
|
|
56
|
+
continue
|
|
57
|
+
headers[name] = val
|
|
58
|
+
|
|
59
|
+
query_raw = scope.get("query_string") or b""
|
|
60
|
+
query = (
|
|
61
|
+
query_raw.decode("latin-1", errors="replace") if query_raw else None
|
|
62
|
+
)
|
|
63
|
+
path = scope.get("path")
|
|
64
|
+
method = scope.get("method")
|
|
65
|
+
client = scope.get("client")
|
|
66
|
+
ip = client[0] if client else None
|
|
67
|
+
|
|
68
|
+
ctx: Dict[str, Any] = {
|
|
69
|
+
"method": method,
|
|
70
|
+
"path": path,
|
|
71
|
+
"query_string": query,
|
|
72
|
+
"headers": headers,
|
|
73
|
+
"ip": ip,
|
|
74
|
+
}
|
|
75
|
+
if send_default_pii and ip:
|
|
76
|
+
ctx["ip_address"] = ip
|
|
77
|
+
return ctx
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
debug(f"asgi scope ctx build failed: {exc}")
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
42
83
|
def build_request_ctx(request: Any, send_default_pii: bool) -> Dict[str, Any]:
|
|
43
84
|
try:
|
|
44
85
|
request = _resolve_drf_request(request)
|
|
@@ -1,30 +1,70 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Connect Django's
|
|
2
|
+
Connect Django's exception path to Insider capture.
|
|
3
|
+
|
|
4
|
+
Patches ``response_for_exception`` so the exception object is captured
|
|
5
|
+
reliably on both WSGI and ASGI (``got_request_exception`` alone is not
|
|
6
|
+
enough — ``sys.exc_info()`` is often cleared before the signal handler runs).
|
|
3
7
|
"""
|
|
4
8
|
|
|
5
9
|
from __future__ import annotations
|
|
6
10
|
|
|
7
11
|
import sys
|
|
8
|
-
from typing import Any
|
|
12
|
+
from typing import Any, Tuple, Type
|
|
9
13
|
|
|
10
14
|
from ...safety import debug, safe
|
|
11
15
|
from .capture import capture_request_exception
|
|
12
16
|
|
|
13
17
|
_connected = False
|
|
18
|
+
_rfe_patched = False
|
|
19
|
+
|
|
20
|
+
# These are converted to 4xx responses without emitting got_request_exception.
|
|
21
|
+
_HANDLED_QUIETLY: Tuple[Type[BaseException], ...] = ()
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
def install() -> None:
|
|
17
|
-
global _connected
|
|
18
|
-
if _connected:
|
|
25
|
+
global _connected, _rfe_patched, _HANDLED_QUIETLY
|
|
26
|
+
if _connected and _rfe_patched:
|
|
19
27
|
return
|
|
20
28
|
try:
|
|
21
29
|
from django.core import signals
|
|
30
|
+
from django.core.exceptions import (
|
|
31
|
+
BadRequest,
|
|
32
|
+
PermissionDenied,
|
|
33
|
+
SuspiciousOperation,
|
|
34
|
+
)
|
|
35
|
+
from django.core.handlers import exception as exception_module
|
|
36
|
+
from django.http import Http404
|
|
37
|
+
from django.http.multipartparser import MultiPartParserError
|
|
22
38
|
except ImportError:
|
|
23
|
-
debug("django signals unavailable; skipping
|
|
39
|
+
debug("django signals unavailable; skipping exception hooks")
|
|
24
40
|
return
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
_HANDLED_QUIETLY = (
|
|
43
|
+
Http404,
|
|
44
|
+
PermissionDenied,
|
|
45
|
+
MultiPartParserError,
|
|
46
|
+
BadRequest,
|
|
47
|
+
SuspiciousOperation,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if not _rfe_patched:
|
|
51
|
+
old_rfe = exception_module.response_for_exception
|
|
52
|
+
|
|
53
|
+
@safe
|
|
54
|
+
def patched_rfe(request: Any, exc: BaseException) -> Any:
|
|
55
|
+
response = old_rfe(request, exc)
|
|
56
|
+
if not isinstance(exc, _HANDLED_QUIETLY) and getattr(
|
|
57
|
+
response, "status_code", 500
|
|
58
|
+
) >= 500:
|
|
59
|
+
capture_request_exception(request, exc)
|
|
60
|
+
return response
|
|
61
|
+
|
|
62
|
+
exception_module.response_for_exception = patched_rfe # type: ignore[attr-defined]
|
|
63
|
+
_rfe_patched = True
|
|
64
|
+
|
|
65
|
+
if not _connected:
|
|
66
|
+
signals.got_request_exception.connect(_on_got_request_exception)
|
|
67
|
+
_connected = True
|
|
28
68
|
|
|
29
69
|
|
|
30
70
|
@safe
|
insider/safety.py
CHANGED
|
@@ -50,6 +50,9 @@ def safe(fn: F) -> F:
|
|
|
50
50
|
the boundary functions catch them.
|
|
51
51
|
"""
|
|
52
52
|
|
|
53
|
+
if asyncio_iscoroutinefunction(fn):
|
|
54
|
+
return safe_async(fn) # type: ignore[return-value]
|
|
55
|
+
|
|
53
56
|
@functools.wraps(fn)
|
|
54
57
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
55
58
|
try:
|
|
@@ -59,3 +62,26 @@ def safe(fn: F) -> F:
|
|
|
59
62
|
return None
|
|
60
63
|
|
|
61
64
|
return wrapper # type: ignore[return-value]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def asyncio_iscoroutinefunction(fn: Any) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
import asyncio
|
|
70
|
+
|
|
71
|
+
return asyncio.iscoroutinefunction(fn)
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def safe_async(fn: F) -> F:
|
|
77
|
+
"""Like `@safe` for async functions — preserves the coroutine contract."""
|
|
78
|
+
|
|
79
|
+
@functools.wraps(fn)
|
|
80
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
try:
|
|
82
|
+
return await fn(*args, **kwargs)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
debug(f"swallowed {type(exc).__name__} in {fn.__qualname__}: {exc}")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
return wrapper # type: ignore[return-value]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: insider-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for Insider — ship Beacons to your Insider server.
|
|
5
5
|
Author: Insider
|
|
6
6
|
License-Expression: MIT
|
|
@@ -70,7 +70,10 @@ Out-of-band events (background jobs, explicit calls) use standalone beacons:
|
|
|
70
70
|
|
|
71
71
|
### Django
|
|
72
72
|
|
|
73
|
-
Initialize in `wsgi.py`
|
|
73
|
+
Initialize in `wsgi.py` or `asgi.py` **before** `get_wsgi_application()` /
|
|
74
|
+
`get_asgi_application()`:
|
|
75
|
+
|
|
76
|
+
#### WSGI (Gunicorn)
|
|
74
77
|
|
|
75
78
|
```python
|
|
76
79
|
import os
|
|
@@ -78,6 +81,8 @@ import insider
|
|
|
78
81
|
from insider.integrations.django import DjangoIntegration
|
|
79
82
|
from insider.integrations.logging import LoggingIntegration
|
|
80
83
|
|
|
84
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
|
85
|
+
|
|
81
86
|
insider.init(
|
|
82
87
|
dsn=os.environ.get("INSIDER_DSN"),
|
|
83
88
|
environment="production",
|
|
@@ -90,6 +95,65 @@ from django.core.wsgi import get_wsgi_application
|
|
|
90
95
|
application = get_wsgi_application()
|
|
91
96
|
```
|
|
92
97
|
|
|
98
|
+
#### ASGI (Daphne / Uvicorn — plain Django)
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import os
|
|
102
|
+
import insider
|
|
103
|
+
from insider.integrations.django import DjangoIntegration
|
|
104
|
+
from insider.integrations.logging import LoggingIntegration
|
|
105
|
+
|
|
106
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
|
107
|
+
|
|
108
|
+
insider.init(
|
|
109
|
+
dsn=os.environ.get("INSIDER_DSN"),
|
|
110
|
+
environment="production",
|
|
111
|
+
release="1.2.3",
|
|
112
|
+
enable_logs=True,
|
|
113
|
+
integrations=[DjangoIntegration(), LoggingIntegration()],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
from django.core.asgi import get_asgi_application
|
|
117
|
+
application = get_asgi_application()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### ASGI + Channels (`ProtocolTypeRouter`)
|
|
121
|
+
|
|
122
|
+
Wrap only the HTTP branch; disable handler auto-perf to avoid double capture:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import os
|
|
126
|
+
import django
|
|
127
|
+
|
|
128
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
|
129
|
+
|
|
130
|
+
import django
|
|
131
|
+
|
|
132
|
+
django.setup()
|
|
133
|
+
|
|
134
|
+
import insider
|
|
135
|
+
from insider.integrations.django import DjangoIntegration
|
|
136
|
+
from insider.integrations.django.asgi import wrap_asgi_application
|
|
137
|
+
from insider.integrations.logging import LoggingIntegration
|
|
138
|
+
|
|
139
|
+
insider.init(
|
|
140
|
+
dsn=os.environ.get("INSIDER_DSN"),
|
|
141
|
+
environment="production",
|
|
142
|
+
enable_logs=True,
|
|
143
|
+
integrations=[DjangoIntegration(auto_perf=False), LoggingIntegration()],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
147
|
+
from django.core.asgi import get_asgi_application
|
|
148
|
+
|
|
149
|
+
application = ProtocolTypeRouter({
|
|
150
|
+
"http": wrap_asgi_application(get_asgi_application()),
|
|
151
|
+
"websocket": URLRouter(websocket_urlpatterns),
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Set `INSIDER_DEBUG=true` to print which hooks installed at startup.
|
|
156
|
+
|
|
93
157
|
That's the whole setup. **Every HTTP request** emits **one** `kind=request`
|
|
94
158
|
beacon containing:
|
|
95
159
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
insider/__init__.py,sha256=5vkZk0y10671uwRx4bvZB05b6CNUsH5jYEsBSOoGiw0,1011
|
|
2
2
|
insider/_envelope.py,sha256=g7VQh4fBRVStzU4n7E_3bxij_x_Kw3p_IaAo9Fh94RE,6690
|
|
3
3
|
insider/_footprint.py,sha256=d123PGVSbmvy5uEXEfQ44h6-V28uGMWjiDBfVcc2Du8,2072
|
|
4
|
-
insider/_version.py,sha256=
|
|
4
|
+
insider/_version.py,sha256=UEiwKKTe6TALAXOWbaJXXvR3Tsia52swt986qSpnCig,246
|
|
5
5
|
insider/client.py,sha256=Bn8f0JQ7igzTjyEyig2mctBzb-hUyIxut6wRJW-RYeI,22085
|
|
6
6
|
insider/dsn.py,sha256=S8BbRhmKKKU1N6W8cpMSQoyDO_EuUJ1PkcjhGrrf5hw,3009
|
|
7
7
|
insider/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
insider/safety.py,sha256=
|
|
8
|
+
insider/safety.py,sha256=OqvVbCLMDANzk7HmtPjr19CAzbi6FoX0fRbL6BOvJf8,2626
|
|
9
9
|
insider/scope.py,sha256=k6FeNTKhkBjR7AwAfvWBTZ4BnvKMoe3OkSX55DgRiO8,6105
|
|
10
10
|
insider/scrubbing.py,sha256=U8N3MS5VtDUNpIWN4PZ8bvrPqmjRRXq5hWGXxzoF5Gw,2853
|
|
11
11
|
insider/stacktrace.py,sha256=tgdS9dQ-bVyz59_874BstuCYaP6rGn2k6HMo9sW8Ks8,5310
|
|
@@ -15,18 +15,19 @@ insider/contrib/django/__init__.py,sha256=UMGrJgVO-FqszZo9Cviw7oWS8PLdXVHt3hwXZ_
|
|
|
15
15
|
insider/contrib/django/apps.py,sha256=Ct584Pa-S9I82omP3t_Xsn4NrdlqfoNf0eUN9xFa7Hs,2036
|
|
16
16
|
insider/contrib/django/middleware.py,sha256=lX5sGDZw4rJ6gsNEcOpqHspkvzqilxTlUxOszQxwzpI,2077
|
|
17
17
|
insider/integrations/__init__.py,sha256=Obf8BJS2kngbCeHCBPCik0jY30_F-sk3qHMCJ1heIFY,616
|
|
18
|
-
insider/integrations/django/__init__.py,sha256=
|
|
19
|
-
insider/integrations/django/
|
|
18
|
+
insider/integrations/django/__init__.py,sha256=HhVeMP3erdA5TYStTIJv4nQN3aAVwjaUiooI-Jl2cF0,3830
|
|
19
|
+
insider/integrations/django/asgi.py,sha256=s9eCAtGC45PDvS1yPhKr9I103aicSmjq83G2WpUW_Nk,3801
|
|
20
|
+
insider/integrations/django/capture.py,sha256=22tea2oSFZQ03M4pKNKxTotoqXG9svi1QPbBP_20K4M,1818
|
|
20
21
|
insider/integrations/django/drf.py,sha256=MXY4erP9gPBiEvf8Hi0CLrUBczXY1o7Y5wVCjSsQeCY,899
|
|
21
|
-
insider/integrations/django/handler.py,sha256=
|
|
22
|
-
insider/integrations/django/perf.py,sha256=
|
|
23
|
-
insider/integrations/django/request.py,sha256=
|
|
24
|
-
insider/integrations/django/signals.py,sha256=
|
|
22
|
+
insider/integrations/django/handler.py,sha256=04fH28hBJzDS5n2pmwZkkBA4is9Sk29rMCh_XjEWAK0,4154
|
|
23
|
+
insider/integrations/django/perf.py,sha256=i5pp3Y3niw4MEFsdRdCOatyrhcxC1imunnePyBVq-xQ,1331
|
|
24
|
+
insider/integrations/django/request.py,sha256=8XhzQgG9Zb2p8UFNt_XEyTDGX9ibSEBnC4WGJSv_XHE,5441
|
|
25
|
+
insider/integrations/django/signals.py,sha256=KFyZ3A0_Jpp0461lAaB_PmsNMAKExQOy73vvRNbWUso,2271
|
|
25
26
|
insider/integrations/django/wsgi.py,sha256=kJ5qo5LUdRYJtp4NkiD38DZzbb__idJU6FAltYhgtus,1044
|
|
26
27
|
insider/integrations/logging/__init__.py,sha256=vjsVWFmC1j6v-ZIcWtOP24JkvbnmvgHYlgpYP2jwR4g,1805
|
|
27
28
|
insider/integrations/logging/handler.py,sha256=s_RfCuVr02I9D6igzgju2vnx0x1qUENyVFtwP5eguFs,2019
|
|
28
29
|
insider/integrations/logging/levels.py,sha256=yYkt_vhJLMigiCj0Bk7UYf3hWpYO90JMp-pvRdQSan8,646
|
|
29
|
-
insider_python-0.
|
|
30
|
-
insider_python-0.
|
|
31
|
-
insider_python-0.
|
|
32
|
-
insider_python-0.
|
|
30
|
+
insider_python-0.3.0.dist-info/METADATA,sha256=NtDP6hxdo9FkCOOILKGd3fNKaER6QVg4PrQicFUtgPU,6346
|
|
31
|
+
insider_python-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
32
|
+
insider_python-0.3.0.dist-info/top_level.txt,sha256=g_YKp2jCaaefmasZ2nOa9capm0X8q2sAWI_eEClKIos,8
|
|
33
|
+
insider_python-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|