insider-python 0.1.4__tar.gz → 0.1.6__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.
Files changed (56) hide show
  1. {insider_python-0.1.4 → insider_python-0.1.6}/PKG-INFO +66 -2
  2. {insider_python-0.1.4 → insider_python-0.1.6}/README.md +65 -1
  3. {insider_python-0.1.4 → insider_python-0.1.6}/pyproject.toml +1 -1
  4. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/_version.py +1 -1
  5. insider_python-0.1.6/src/insider/integrations/django/__init__.py +121 -0
  6. insider_python-0.1.6/src/insider/integrations/django/asgi.py +123 -0
  7. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/django/capture.py +14 -0
  8. insider_python-0.1.6/src/insider/integrations/django/handler.py +133 -0
  9. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/django/perf.py +22 -5
  10. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/django/request.py +41 -0
  11. insider_python-0.1.6/src/insider/integrations/django/signals.py +79 -0
  12. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/safety.py +26 -0
  13. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider_python.egg-info/PKG-INFO +66 -2
  14. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider_python.egg-info/SOURCES.txt +2 -0
  15. insider_python-0.1.6/tests/test_asgi_integration.py +138 -0
  16. insider_python-0.1.4/src/insider/integrations/django/__init__.py +0 -60
  17. insider_python-0.1.4/src/insider/integrations/django/handler.py +0 -71
  18. insider_python-0.1.4/src/insider/integrations/django/signals.py +0 -39
  19. {insider_python-0.1.4 → insider_python-0.1.6}/setup.cfg +0 -0
  20. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/__init__.py +0 -0
  21. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/_envelope.py +0 -0
  22. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/_footprint.py +0 -0
  23. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/client.py +0 -0
  24. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/contrib/__init__.py +0 -0
  25. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/contrib/django/__init__.py +0 -0
  26. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/contrib/django/apps.py +0 -0
  27. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/contrib/django/middleware.py +0 -0
  28. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/dsn.py +0 -0
  29. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/__init__.py +0 -0
  30. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/django/drf.py +0 -0
  31. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/django/wsgi.py +0 -0
  32. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/logging/__init__.py +0 -0
  33. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/logging/handler.py +0 -0
  34. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/integrations/logging/levels.py +0 -0
  35. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/py.typed +0 -0
  36. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/scope.py +0 -0
  37. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/scrubbing.py +0 -0
  38. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/stacktrace.py +0 -0
  39. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider/transport.py +0 -0
  40. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider_python.egg-info/dependency_links.txt +0 -0
  41. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider_python.egg-info/requires.txt +0 -0
  42. {insider_python-0.1.4 → insider_python-0.1.6}/src/insider_python.egg-info/top_level.txt +0 -0
  43. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_capture.py +0 -0
  44. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_capture_log.py +0 -0
  45. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_capture_perf.py +0 -0
  46. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_django.py +0 -0
  47. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_django_integration.py +0 -0
  48. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_drf_integration.py +0 -0
  49. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_dsn.py +0 -0
  50. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_envelope.py +0 -0
  51. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_logging_integration.py +0 -0
  52. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_never_crash.py +0 -0
  53. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_safety.py +0 -0
  54. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_scrubbing.py +0 -0
  55. {insider_python-0.1.4 → insider_python-0.1.6}/tests/test_stacktrace.py +0 -0
  56. {insider_python-0.1.4 → insider_python-0.1.6}/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.4
3
+ Version: 0.1.6
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` (or `asgi.py`) before `get_wsgi_application()`:
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` (or `asgi.py`) before `get_wsgi_application()`:
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "insider-python"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "Python SDK for Insider — ship Beacons to your Insider server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -5,4 +5,4 @@ lookup on every beacon. Bump this and `[project].version` together when
5
5
  cutting a release.
6
6
  """
7
7
 
8
- __version__ = "0.1.4"
8
+ __version__ = "0.1.6"
@@ -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 emit_request_envelope(
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
- method = getattr(request, "method", None)
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)