insider-python 0.1.2__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.
Files changed (44) hide show
  1. {insider_python-0.1.2 → insider_python-0.1.3}/PKG-INFO +23 -17
  2. {insider_python-0.1.2 → insider_python-0.1.3}/README.md +21 -16
  3. {insider_python-0.1.2 → insider_python-0.1.3}/pyproject.toml +2 -1
  4. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/_version.py +1 -1
  5. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/client.py +19 -1
  6. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/contrib/django/apps.py +2 -1
  7. insider_python-0.1.3/src/insider/contrib/django/middleware.py +65 -0
  8. insider_python-0.1.3/src/insider/integrations/__init__.py +20 -0
  9. insider_python-0.1.3/src/insider/integrations/django/__init__.py +50 -0
  10. insider_python-0.1.3/src/insider/integrations/django/capture.py +43 -0
  11. insider_python-0.1.3/src/insider/integrations/django/drf.py +40 -0
  12. insider_python-0.1.3/src/insider/integrations/django/handler.py +42 -0
  13. insider_python-0.1.3/src/insider/integrations/django/request.py +140 -0
  14. insider_python-0.1.3/src/insider/integrations/django/signals.py +39 -0
  15. insider_python-0.1.3/src/insider/integrations/django/wsgi.py +43 -0
  16. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider_python.egg-info/PKG-INFO +23 -17
  17. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider_python.egg-info/SOURCES.txt +10 -0
  18. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider_python.egg-info/requires.txt +1 -0
  19. insider_python-0.1.3/tests/test_django_integration.py +58 -0
  20. insider_python-0.1.3/tests/test_drf_integration.py +59 -0
  21. insider_python-0.1.2/src/insider/contrib/django/middleware.py +0 -164
  22. {insider_python-0.1.2 → insider_python-0.1.3}/setup.cfg +0 -0
  23. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/__init__.py +0 -0
  24. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/_envelope.py +0 -0
  25. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/contrib/__init__.py +0 -0
  26. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/contrib/django/__init__.py +0 -0
  27. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/dsn.py +0 -0
  28. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/py.typed +0 -0
  29. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/safety.py +0 -0
  30. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/scope.py +0 -0
  31. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/scrubbing.py +0 -0
  32. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/stacktrace.py +0 -0
  33. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider/transport.py +0 -0
  34. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider_python.egg-info/dependency_links.txt +0 -0
  35. {insider_python-0.1.2 → insider_python-0.1.3}/src/insider_python.egg-info/top_level.txt +0 -0
  36. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_capture.py +0 -0
  37. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_django.py +0 -0
  38. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_dsn.py +0 -0
  39. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_envelope.py +0 -0
  40. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_never_crash.py +0 -0
  41. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_safety.py +0 -0
  42. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_scrubbing.py +0 -0
  43. {insider_python-0.1.2 → insider_python-0.1.3}/tests/test_stacktrace.py +0 -0
  44. {insider_python-0.1.2 → 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.2
3
+ Version: 0.1.3
4
4
  Summary: Python SDK for Insider — ship Beacons to your Insider server.
5
5
  Author: Insider
6
6
  License-Expression: MIT
@@ -24,6 +24,7 @@ Provides-Extra: dev
24
24
  Requires-Dist: pytest>=8; extra == "dev"
25
25
  Requires-Dist: pytest-django>=4.8; extra == "dev"
26
26
  Requires-Dist: django>=4.2; extra == "dev"
27
+ Requires-Dist: djangorestframework>=3.14; extra == "dev"
27
28
 
28
29
  # insider-python
29
30
 
@@ -68,26 +69,31 @@ insider.capture_message("cache miss spiked", level="warning")
68
69
 
69
70
  ### Django
70
71
 
71
- Add the integration to `INSTALLED_APPS` and configure via settings:
72
+ Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
72
73
 
73
74
  ```python
74
- INSTALLED_APPS = [
75
- # ...
76
- "insider.contrib.django",
77
- ]
78
-
79
- MIDDLEWARE = [
80
- # ...
81
- "insider.contrib.django.middleware.InsiderMiddleware",
82
- ]
83
-
84
- INSIDER_DSN = "https://<beacon_token>@insider.example.com/<project_uuid>"
85
- INSIDER_ENVIRONMENT = "production"
86
- INSIDER_RELEASE = "1.2.3"
75
+ import os
76
+ import insider
77
+ from insider.integrations.django import DjangoIntegration
78
+
79
+ insider.init(
80
+ dsn=os.environ.get("INSIDER_DSN"),
81
+ environment="production",
82
+ release="1.2.3",
83
+ integrations=[DjangoIntegration()],
84
+ )
85
+
86
+ from django.core.wsgi import get_wsgi_application
87
+ application = get_wsgi_application()
87
88
  ```
88
89
 
89
- That's the whole setup. Every unhandled exception in a view is now a
90
- Beacon in your dashboard.
90
+ That's the whole setup. Every unhandled exception in a view including
91
+ Django REST Framework API views — is now a Beacon in your dashboard.
92
+ No middleware, no `INSTALLED_APPS`, and no `EXCEPTION_HANDLER` wiring.
93
+
94
+ **Legacy setup** (still supported): add `insider.contrib.django` to
95
+ `INSTALLED_APPS` and `InsiderMiddleware` to `MIDDLEWARE`. Prefer the
96
+ `wsgi.py` pattern for new projects.
91
97
 
92
98
  ## Configuration
93
99
 
@@ -41,26 +41,31 @@ insider.capture_message("cache miss spiked", level="warning")
41
41
 
42
42
  ### Django
43
43
 
44
- Add the integration to `INSTALLED_APPS` and configure via settings:
44
+ Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
45
45
 
46
46
  ```python
47
- INSTALLED_APPS = [
48
- # ...
49
- "insider.contrib.django",
50
- ]
51
-
52
- MIDDLEWARE = [
53
- # ...
54
- "insider.contrib.django.middleware.InsiderMiddleware",
55
- ]
56
-
57
- INSIDER_DSN = "https://<beacon_token>@insider.example.com/<project_uuid>"
58
- INSIDER_ENVIRONMENT = "production"
59
- INSIDER_RELEASE = "1.2.3"
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 is now a
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "insider-python"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Python SDK for Insider — ship Beacons to your Insider server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -32,6 +32,7 @@ dev = [
32
32
  "pytest>=8",
33
33
  "pytest-django>=4.8",
34
34
  "django>=4.2",
35
+ "djangorestframework>=3.14",
35
36
  ]
36
37
 
37
38
  [project.urls]
@@ -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.2"
8
+ __version__ = "0.1.3"
@@ -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.2
3
+ Version: 0.1.3
4
4
  Summary: Python SDK for Insider — ship Beacons to your Insider server.
5
5
  Author: Insider
6
6
  License-Expression: MIT
@@ -24,6 +24,7 @@ Provides-Extra: dev
24
24
  Requires-Dist: pytest>=8; extra == "dev"
25
25
  Requires-Dist: pytest-django>=4.8; extra == "dev"
26
26
  Requires-Dist: django>=4.2; extra == "dev"
27
+ Requires-Dist: djangorestframework>=3.14; extra == "dev"
27
28
 
28
29
  # insider-python
29
30
 
@@ -68,26 +69,31 @@ insider.capture_message("cache miss spiked", level="warning")
68
69
 
69
70
  ### Django
70
71
 
71
- Add the integration to `INSTALLED_APPS` and configure via settings:
72
+ Initialize in `wsgi.py` (or `asgi.py`) before `get_wsgi_application()`:
72
73
 
73
74
  ```python
74
- INSTALLED_APPS = [
75
- # ...
76
- "insider.contrib.django",
77
- ]
78
-
79
- MIDDLEWARE = [
80
- # ...
81
- "insider.contrib.django.middleware.InsiderMiddleware",
82
- ]
83
-
84
- INSIDER_DSN = "https://<beacon_token>@insider.example.com/<project_uuid>"
85
- INSIDER_ENVIRONMENT = "production"
86
- INSIDER_RELEASE = "1.2.3"
75
+ import os
76
+ import insider
77
+ from insider.integrations.django import DjangoIntegration
78
+
79
+ insider.init(
80
+ dsn=os.environ.get("INSIDER_DSN"),
81
+ environment="production",
82
+ release="1.2.3",
83
+ integrations=[DjangoIntegration()],
84
+ )
85
+
86
+ from django.core.wsgi import get_wsgi_application
87
+ application = get_wsgi_application()
87
88
  ```
88
89
 
89
- That's the whole setup. Every unhandled exception in a view is now a
90
- Beacon in your dashboard.
90
+ That's the whole setup. Every unhandled exception in a view including
91
+ Django REST Framework API views — is now a Beacon in your dashboard.
92
+ No middleware, no `INSTALLED_APPS`, and no `EXCEPTION_HANDLER` wiring.
93
+
94
+ **Legacy setup** (still supported): add `insider.contrib.django` to
95
+ `INSTALLED_APPS` and `InsiderMiddleware` to `MIDDLEWARE`. Prefer the
96
+ `wsgi.py` pattern for new projects.
91
97
 
92
98
  ## Configuration
93
99
 
@@ -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
@@ -4,6 +4,7 @@ urllib3>=1.26
4
4
  pytest>=8
5
5
  pytest-django>=4.8
6
6
  django>=4.2
7
+ djangorestframework>=3.14
7
8
 
8
9
  [django]
9
10
  django>=4.2
@@ -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