insider-python 0.1.4__tar.gz → 0.1.5__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.4 → insider_python-0.1.5}/PKG-INFO +66 -2
- {insider_python-0.1.4 → insider_python-0.1.5}/README.md +65 -1
- {insider_python-0.1.4 → insider_python-0.1.5}/pyproject.toml +1 -1
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/_version.py +1 -1
- insider_python-0.1.5/src/insider/integrations/django/__init__.py +121 -0
- insider_python-0.1.5/src/insider/integrations/django/asgi.py +123 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/django/capture.py +14 -0
- insider_python-0.1.5/src/insider/integrations/django/handler.py +133 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/django/perf.py +22 -5
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/django/request.py +41 -0
- insider_python-0.1.5/src/insider/integrations/django/signals.py +79 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/safety.py +26 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider_python.egg-info/PKG-INFO +66 -2
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider_python.egg-info/SOURCES.txt +2 -0
- insider_python-0.1.5/tests/test_asgi_integration.py +138 -0
- insider_python-0.1.4/src/insider/integrations/django/__init__.py +0 -60
- insider_python-0.1.4/src/insider/integrations/django/handler.py +0 -71
- insider_python-0.1.4/src/insider/integrations/django/signals.py +0 -39
- {insider_python-0.1.4 → insider_python-0.1.5}/setup.cfg +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/__init__.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/_envelope.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/_footprint.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/client.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/contrib/__init__.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/contrib/django/__init__.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/contrib/django/apps.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/contrib/django/middleware.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/dsn.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/__init__.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/django/drf.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/django/wsgi.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/logging/__init__.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/logging/handler.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/integrations/logging/levels.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/py.typed +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/scope.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/scrubbing.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/stacktrace.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider/transport.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider_python.egg-info/dependency_links.txt +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider_python.egg-info/requires.txt +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/src/insider_python.egg-info/top_level.txt +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_capture.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_capture_log.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_capture_perf.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_django.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_django_integration.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_drf_integration.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_dsn.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_envelope.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_logging_integration.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_never_crash.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_safety.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_scrubbing.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/tests/test_stacktrace.py +0 -0
- {insider_python-0.1.4 → insider_python-0.1.5}/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.5
|
|
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
|
|
|
@@ -42,7 +42,10 @@ Out-of-band events (background jobs, explicit calls) use standalone beacons:
|
|
|
42
42
|
|
|
43
43
|
### Django
|
|
44
44
|
|
|
45
|
-
Initialize in `wsgi.py`
|
|
45
|
+
Initialize in `wsgi.py` or `asgi.py` **before** `get_wsgi_application()` /
|
|
46
|
+
`get_asgi_application()`:
|
|
47
|
+
|
|
48
|
+
#### WSGI (Gunicorn)
|
|
46
49
|
|
|
47
50
|
```python
|
|
48
51
|
import os
|
|
@@ -50,6 +53,8 @@ import insider
|
|
|
50
53
|
from insider.integrations.django import DjangoIntegration
|
|
51
54
|
from insider.integrations.logging import LoggingIntegration
|
|
52
55
|
|
|
56
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
|
57
|
+
|
|
53
58
|
insider.init(
|
|
54
59
|
dsn=os.environ.get("INSIDER_DSN"),
|
|
55
60
|
environment="production",
|
|
@@ -62,6 +67,65 @@ from django.core.wsgi import get_wsgi_application
|
|
|
62
67
|
application = get_wsgi_application()
|
|
63
68
|
```
|
|
64
69
|
|
|
70
|
+
#### ASGI (Daphne / Uvicorn — plain Django)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import os
|
|
74
|
+
import insider
|
|
75
|
+
from insider.integrations.django import DjangoIntegration
|
|
76
|
+
from insider.integrations.logging import LoggingIntegration
|
|
77
|
+
|
|
78
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
|
79
|
+
|
|
80
|
+
insider.init(
|
|
81
|
+
dsn=os.environ.get("INSIDER_DSN"),
|
|
82
|
+
environment="production",
|
|
83
|
+
release="1.2.3",
|
|
84
|
+
enable_logs=True,
|
|
85
|
+
integrations=[DjangoIntegration(), LoggingIntegration()],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
from django.core.asgi import get_asgi_application
|
|
89
|
+
application = get_asgi_application()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### ASGI + Channels (`ProtocolTypeRouter`)
|
|
93
|
+
|
|
94
|
+
Wrap only the HTTP branch; disable handler auto-perf to avoid double capture:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import os
|
|
98
|
+
import django
|
|
99
|
+
|
|
100
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
|
101
|
+
|
|
102
|
+
import django
|
|
103
|
+
|
|
104
|
+
django.setup()
|
|
105
|
+
|
|
106
|
+
import insider
|
|
107
|
+
from insider.integrations.django import DjangoIntegration
|
|
108
|
+
from insider.integrations.django.asgi import wrap_asgi_application
|
|
109
|
+
from insider.integrations.logging import LoggingIntegration
|
|
110
|
+
|
|
111
|
+
insider.init(
|
|
112
|
+
dsn=os.environ.get("INSIDER_DSN"),
|
|
113
|
+
environment="production",
|
|
114
|
+
enable_logs=True,
|
|
115
|
+
integrations=[DjangoIntegration(auto_perf=False), LoggingIntegration()],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
119
|
+
from django.core.asgi import get_asgi_application
|
|
120
|
+
|
|
121
|
+
application = ProtocolTypeRouter({
|
|
122
|
+
"http": wrap_asgi_application(get_asgi_application()),
|
|
123
|
+
"websocket": URLRouter(websocket_urlpatterns),
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Set `INSIDER_DEBUG=true` to print which hooks installed at startup.
|
|
128
|
+
|
|
65
129
|
That's the whole setup. **Every HTTP request** emits **one** `kind=request`
|
|
66
130
|
beacon containing:
|
|
67
131
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sentry-style Django integration.
|
|
3
|
+
|
|
4
|
+
Install with `insider.init(..., integrations=[DjangoIntegration()])` in
|
|
5
|
+
`wsgi.py` / `asgi.py` before `get_wsgi_application()` or
|
|
6
|
+
`get_asgi_application()`. No middleware or `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 (WSGI + ASGI HTTP)
|
|
12
|
+
- `WSGIHandler.__call__` → capture catastrophic WSGI escapes
|
|
13
|
+
- `ASGIHandler.__call__` → capture catastrophic ASGI escapes
|
|
14
|
+
- `APIView.initial` (when DRF is present) → DRF request body access
|
|
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`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
from typing import Any, Dict
|
|
25
|
+
|
|
26
|
+
from ...safety import debug
|
|
27
|
+
from . import asgi, drf, handler, signals, wsgi
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DjangoIntegration:
|
|
31
|
+
"""Patch Django's request/exception path — one request beacon per HTTP cycle."""
|
|
32
|
+
|
|
33
|
+
identifier = "django"
|
|
34
|
+
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
_installed = False
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, auto_perf: bool = True) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Args:
|
|
41
|
+
auto_perf: When True (default), emit one `kind=request` beacon
|
|
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).
|
|
45
|
+
"""
|
|
46
|
+
self.auto_perf = auto_perf
|
|
47
|
+
|
|
48
|
+
def setup_once(self) -> None:
|
|
49
|
+
cls = type(self)
|
|
50
|
+
with cls._lock:
|
|
51
|
+
if cls._installed:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import django # noqa: F401
|
|
56
|
+
except ImportError:
|
|
57
|
+
debug("DjangoIntegration: django is not installed")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Patch get_response first. Optional hooks below must not block this.
|
|
61
|
+
handler.install(auto_perf=self.auto_perf)
|
|
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)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Patch `BaseHandler.get_response` / `get_response_async` for request context
|
|
3
|
+
and auto perf timing.
|
|
4
|
+
|
|
5
|
+
We patch the handler (not middleware) because:
|
|
6
|
+
|
|
7
|
+
- Customers already init in `wsgi.py` / `asgi.py` without touching INSTALLED_APPS.
|
|
8
|
+
- The patch wraps the entire handler, including middleware inside Django's
|
|
9
|
+
request cycle.
|
|
10
|
+
- Perf timing in `finally` runs once per request whether the view returns
|
|
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.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
from ...client import _client
|
|
24
|
+
from ...safety import debug, safe
|
|
25
|
+
from .perf import emit_request_envelope
|
|
26
|
+
from .capture import sync_pending_from_request
|
|
27
|
+
|
|
28
|
+
_patched = False
|
|
29
|
+
_auto_perf = True
|
|
30
|
+
|
|
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
|
+
|
|
59
|
+
def install(*, auto_perf: bool = True) -> None:
|
|
60
|
+
global _patched, _auto_perf
|
|
61
|
+
_auto_perf = auto_perf
|
|
62
|
+
if _patched:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
from django.core.handlers.base import BaseHandler
|
|
66
|
+
except ImportError:
|
|
67
|
+
debug("django BaseHandler unavailable; skipping get_response patch")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
old_get_response = BaseHandler.get_response
|
|
71
|
+
old_get_response_async = BaseHandler.get_response_async
|
|
72
|
+
|
|
73
|
+
@safe
|
|
74
|
+
def patched_get_response(self: Any, request: Any) -> Any:
|
|
75
|
+
client = _client()
|
|
76
|
+
if client is None:
|
|
77
|
+
return old_get_response(self, request)
|
|
78
|
+
|
|
79
|
+
trace_id = uuid.uuid4().hex
|
|
80
|
+
client.scope.set_trace_id(trace_id)
|
|
81
|
+
from .request import build_request_ctx
|
|
82
|
+
|
|
83
|
+
ctx = build_request_ctx(request, client.send_default_pii)
|
|
84
|
+
client.scope.set_request(ctx)
|
|
85
|
+
|
|
86
|
+
start = time.perf_counter()
|
|
87
|
+
response = None
|
|
88
|
+
status_code: int | None = None
|
|
89
|
+
try:
|
|
90
|
+
response = old_get_response(self, request)
|
|
91
|
+
status_code = getattr(response, "status_code", None)
|
|
92
|
+
return response
|
|
93
|
+
finally:
|
|
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
|
+
)
|
|
130
|
+
|
|
131
|
+
BaseHandler.get_response = patched_get_response # type: ignore[method-assign]
|
|
132
|
+
BaseHandler.get_response_async = patched_get_response_async # type: ignore[method-assign]
|
|
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)
|