errorgap 0.2.0__tar.gz → 0.4.0__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 (39) hide show
  1. {errorgap-0.2.0 → errorgap-0.4.0}/CHANGELOG.md +17 -0
  2. {errorgap-0.2.0 → errorgap-0.4.0}/PKG-INFO +42 -3
  3. {errorgap-0.2.0 → errorgap-0.4.0}/README.md +41 -2
  4. {errorgap-0.2.0 → errorgap-0.4.0}/pyproject.toml +1 -1
  5. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/__init__.py +28 -0
  6. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/client.py +23 -0
  7. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/configuration.py +5 -0
  8. errorgap-0.4.0/src/errorgap/django.py +178 -0
  9. errorgap-0.4.0/src/errorgap/fastapi.py +120 -0
  10. errorgap-0.4.0/src/errorgap/sign_ins.py +85 -0
  11. errorgap-0.4.0/src/errorgap/version.py +1 -0
  12. errorgap-0.4.0/tests/test_django.py +137 -0
  13. errorgap-0.4.0/tests/test_fastapi.py +82 -0
  14. errorgap-0.4.0/tests/test_sign_ins.py +50 -0
  15. errorgap-0.2.0/src/errorgap/django.py +0 -83
  16. errorgap-0.2.0/src/errorgap/fastapi.py +0 -67
  17. errorgap-0.2.0/src/errorgap/version.py +0 -1
  18. errorgap-0.2.0/tests/test_django.py +0 -65
  19. errorgap-0.2.0/tests/test_fastapi.py +0 -39
  20. {errorgap-0.2.0 → errorgap-0.4.0}/.github/workflows/ci.yml +0 -0
  21. {errorgap-0.2.0 → errorgap-0.4.0}/.github/workflows/release.yml +0 -0
  22. {errorgap-0.2.0 → errorgap-0.4.0}/.gitignore +0 -0
  23. {errorgap-0.2.0 → errorgap-0.4.0}/LICENSE +0 -0
  24. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/apm.py +0 -0
  25. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/backtrace.py +0 -0
  26. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/filter.py +0 -0
  27. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/flask.py +0 -0
  28. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/handlers.py +0 -0
  29. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/notice.py +0 -0
  30. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/transaction_context.py +0 -0
  31. {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/wsgi.py +0 -0
  32. {errorgap-0.2.0 → errorgap-0.4.0}/tests/conftest.py +0 -0
  33. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_apm.py +0 -0
  34. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_backtrace.py +0 -0
  35. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_client.py +0 -0
  36. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_configuration.py +0 -0
  37. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_filter.py +0 -0
  38. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_flask.py +0 -0
  39. {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_notice.py +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-06
4
+
5
+ - Sign-ins: `init(auth_events=True)` reports sign-ins to errorgap's
6
+ Security › Logins. Django's `user_logged_in` / `user_login_failed` signals
7
+ are reported automatically with the middleware installed;
8
+ `errorgap.sign_in(outcome, user=, request=)` covers Flask, FastAPI and
9
+ other outcomes (`password_reset`, `mfa_failure`, `locked`). `app_name`
10
+ (or `ERRORGAP_APP_NAME`) names the app. Passwords and tokens are never sent.
11
+
12
+ ## 0.3.0 — 2026-10-04
13
+
14
+ - Django and FastAPI/Starlette middleware now time each request as an APM
15
+ transaction (with `apm_enabled`), grouped by URL pattern / route path, with
16
+ errors linked to their request and the browser's `x-errorgap-trace` header
17
+ recorded. `errorgap.django.spans(request)` / `errorgap.fastapi.spans(request)`
18
+ record spans.
19
+
3
20
  ## 0.2.0 — 2026-10-03
4
21
 
5
22
  - APM: `track_transaction` / `track_job` context managers, `notify_transaction`,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: errorgap
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Python notifier for Errorgap error tracking.
5
5
  Project-URL: Homepage, https://github.com/errorgaphq/errorgap-python
6
6
  Project-URL: Source, https://github.com/errorgaphq/errorgap-python
@@ -97,6 +97,11 @@ MIDDLEWARE = [
97
97
  ]
98
98
  ```
99
99
 
100
+ With `apm_enabled=True` each request is also an APM transaction grouped by
101
+ its URL pattern (`/orders/<int:order_id>`); errors raised during it carry its
102
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
103
+ Record spans from a view with `errorgap.django.spans(request).database(sql, ms)`.
104
+
100
105
  ## Flask
101
106
 
102
107
  ```python
@@ -155,8 +160,8 @@ concurrent threads and asyncio tasks never share one;
155
160
  ## Browser trace links
156
161
 
157
162
  When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
158
- API calls send an `x-errorgap-trace` header. The WSGI middleware (and so
159
- `init_app`) records it, and `track_transaction(..., trace_id=header)` accepts
163
+ API calls send an `x-errorgap-trace` header. The WSGI, Django and FastAPI
164
+ middleware (and so Flask's `init_app`) record it, and `track_transaction(..., trace_id=header)` accepts
160
165
  it, so errorgap's browser Performance view links each call to the server
161
166
  request that answered it. Malformed values are ignored.
162
167
 
@@ -170,6 +175,38 @@ app = FastAPI()
170
175
  app.add_middleware(ErrorgapMiddleware)
171
176
  ```
172
177
 
178
+ With `apm_enabled=True` each request is also an APM transaction grouped by
179
+ its route path (`/orders/{order_id}`); errors raised during it carry its
180
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
181
+ Record spans from an endpoint with `errorgap.fastapi.spans(request).database(sql, ms)`
182
+ (take `request: Request` as a parameter).
183
+
184
+ ## Sign-ins
185
+
186
+ Report sign-ins to your app; errorgap shows them beside SSH logins in
187
+ Security › Logins and flags a new IP, country or hour for a user. Off until
188
+ you opt in, since events carry user names and IPs:
189
+
190
+ ```python
191
+ errorgap.init(project_slug="ox-coffee", auth_events=True, app_name="oxcoffee-web")
192
+ ```
193
+
194
+ **Django** needs nothing else: with `ErrorgapMiddleware` installed, Django's
195
+ `user_logged_in` and `user_login_failed` signals are reported (the user's
196
+ `get_username()`; Django masks the password before the signal). Elsewhere,
197
+ or for other outcomes:
198
+
199
+ ```python
200
+ errorgap.sign_in("success" if ok else "failure", user=email, request=request)
201
+ errorgap.sign_in("mfa_failure", user=email, request=request)
202
+ ```
203
+
204
+ Outcomes: `success`, `failure`, `password_reset`, `mfa_failure`, `locked`.
205
+ A Django, Flask or Starlette `request` supplies the IP, user agent and path
206
+ (without the query string); behind a proxy, pass `ip=` yourself. Never pass
207
+ passwords or tokens; errorgap can also store user names hashed
208
+ (Security › Logins › Web apps › Privacy).
209
+
173
210
  ## Configuration reference
174
211
 
175
212
  | Argument | Default | Notes |
@@ -185,6 +222,8 @@ app.add_middleware(ErrorgapMiddleware)
185
222
  | `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
186
223
  | `apm_enabled` | `False` | Send APM transactions |
187
224
  | `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
225
+ | `auth_events` | `False` | Send sign-ins (`sign_in`, Django login signals) |
226
+ | `app_name` | `ERRORGAP_APP_NAME` or the project slug | The app's name in Security › Logins |
188
227
  | `capture_globals` | `True` | Install `sys.excepthook` |
189
228
 
190
229
  ## Graceful shutdown
@@ -61,6 +61,11 @@ MIDDLEWARE = [
61
61
  ]
62
62
  ```
63
63
 
64
+ With `apm_enabled=True` each request is also an APM transaction grouped by
65
+ its URL pattern (`/orders/<int:order_id>`); errors raised during it carry its
66
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
67
+ Record spans from a view with `errorgap.django.spans(request).database(sql, ms)`.
68
+
64
69
  ## Flask
65
70
 
66
71
  ```python
@@ -119,8 +124,8 @@ concurrent threads and asyncio tasks never share one;
119
124
  ## Browser trace links
120
125
 
121
126
  When the errorgap browser SDK (`@errorgap/browser` 0.3+) is on the page, its
122
- API calls send an `x-errorgap-trace` header. The WSGI middleware (and so
123
- `init_app`) records it, and `track_transaction(..., trace_id=header)` accepts
127
+ API calls send an `x-errorgap-trace` header. The WSGI, Django and FastAPI
128
+ middleware (and so Flask's `init_app`) record it, and `track_transaction(..., trace_id=header)` accepts
124
129
  it, so errorgap's browser Performance view links each call to the server
125
130
  request that answered it. Malformed values are ignored.
126
131
 
@@ -134,6 +139,38 @@ app = FastAPI()
134
139
  app.add_middleware(ErrorgapMiddleware)
135
140
  ```
136
141
 
142
+ With `apm_enabled=True` each request is also an APM transaction grouped by
143
+ its route path (`/orders/{order_id}`); errors raised during it carry its
144
+ transaction id, and the browser SDK's `x-errorgap-trace` header is recorded.
145
+ Record spans from an endpoint with `errorgap.fastapi.spans(request).database(sql, ms)`
146
+ (take `request: Request` as a parameter).
147
+
148
+ ## Sign-ins
149
+
150
+ Report sign-ins to your app; errorgap shows them beside SSH logins in
151
+ Security › Logins and flags a new IP, country or hour for a user. Off until
152
+ you opt in, since events carry user names and IPs:
153
+
154
+ ```python
155
+ errorgap.init(project_slug="ox-coffee", auth_events=True, app_name="oxcoffee-web")
156
+ ```
157
+
158
+ **Django** needs nothing else: with `ErrorgapMiddleware` installed, Django's
159
+ `user_logged_in` and `user_login_failed` signals are reported (the user's
160
+ `get_username()`; Django masks the password before the signal). Elsewhere,
161
+ or for other outcomes:
162
+
163
+ ```python
164
+ errorgap.sign_in("success" if ok else "failure", user=email, request=request)
165
+ errorgap.sign_in("mfa_failure", user=email, request=request)
166
+ ```
167
+
168
+ Outcomes: `success`, `failure`, `password_reset`, `mfa_failure`, `locked`.
169
+ A Django, Flask or Starlette `request` supplies the IP, user agent and path
170
+ (without the query string); behind a proxy, pass `ip=` yourself. Never pass
171
+ passwords or tokens; errorgap can also store user names hashed
172
+ (Security › Logins › Web apps › Privacy).
173
+
137
174
  ## Configuration reference
138
175
 
139
176
  | Argument | Default | Notes |
@@ -149,6 +186,8 @@ app.add_middleware(ErrorgapMiddleware)
149
186
  | `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
150
187
  | `apm_enabled` | `False` | Send APM transactions |
151
188
  | `apm_sample_rate` | `1.0` | Fraction of transactions sent (errors are unaffected) |
189
+ | `auth_events` | `False` | Send sign-ins (`sign_in`, Django login signals) |
190
+ | `app_name` | `ERRORGAP_APP_NAME` or the project slug | The app's name in Security › Logins |
152
191
  | `capture_globals` | `True` | Install `sys.excepthook` |
153
192
 
154
193
  ## Graceful shutdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "errorgap"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Python notifier for Errorgap error tracking."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -18,6 +18,7 @@ __all__ = [
18
18
  "notify_transaction",
19
19
  "track_transaction",
20
20
  "track_job",
21
+ "sign_in",
21
22
  "current_transaction_id",
22
23
  "transaction_scope",
23
24
  "browser_trace_id",
@@ -53,6 +54,8 @@ def init(
53
54
  logger: Optional[Any] = None,
54
55
  apm_enabled: Optional[bool] = None,
55
56
  apm_sample_rate: Optional[float] = None,
57
+ auth_events: Optional[bool] = None,
58
+ app_name: Optional[str] = None,
56
59
  capture_globals: bool = True,
57
60
  ) -> None:
58
61
  """Configure the SDK and install global error hooks.
@@ -86,6 +89,10 @@ def init(
86
89
  overrides["apm_enabled"] = apm_enabled
87
90
  if apm_sample_rate is not None:
88
91
  overrides["apm_sample_rate"] = apm_sample_rate
92
+ if auth_events is not None:
93
+ overrides["auth_events"] = auth_events
94
+ if app_name is not None:
95
+ overrides["app_name"] = app_name
89
96
 
90
97
  _configuration = Configuration(**overrides)
91
98
  _client.configure(_configuration)
@@ -114,6 +121,27 @@ def notify(
114
121
  )
115
122
 
116
123
 
124
+ def sign_in(
125
+ outcome: str,
126
+ user: Any = None,
127
+ request: Any = None,
128
+ ip: Optional[str] = None,
129
+ user_agent: Optional[str] = None,
130
+ path: Optional[str] = None,
131
+ method: Optional[str] = None,
132
+ sync: bool = False,
133
+ ) -> DeliveryResult:
134
+ """Report a sign-in to this app: ``"success"``, ``"failure"``,
135
+ ``"password_reset"``, ``"mfa_failure"`` or ``"locked"``. Pass the
136
+ Django, Flask or Starlette ``request`` for its IP, user agent and path.
137
+ Needs ``init(auth_events=True)``; Django's login signals report
138
+ automatically with the middleware installed."""
139
+ return _client.sign_in(
140
+ outcome, sync=sync, user=user, request=request, ip=ip,
141
+ user_agent=user_agent, path=path, method=method,
142
+ )
143
+
144
+
117
145
  def notify_transaction(transaction: Transaction, sync: bool = False) -> DeliveryResult:
118
146
  """Deliver a pre-measured APM transaction."""
119
147
  return _client.notify_transaction(transaction, sync=sync)
@@ -13,6 +13,7 @@ from urllib import request as urlrequest
13
13
  from .apm import Transaction
14
14
  from .configuration import Configuration
15
15
  from .notice import build_notice
16
+ from .sign_ins import build_sign_in, sign_in_payload
16
17
  from .transaction_context import current_transaction_id
17
18
  from .version import VERSION
18
19
 
@@ -98,6 +99,28 @@ class Client:
98
99
  self._queue.put(("transactions", payload))
99
100
  return DeliveryResult(queued=True, status=202)
100
101
 
102
+ def sign_in(self, outcome: str, sync: bool = False, **fields: Any) -> DeliveryResult:
103
+ """Report a sign-in to this app (Security › Logins). Dropped unless
104
+ ``auth_events`` is on, or when the outcome is unknown."""
105
+ try:
106
+ self._configuration.validate()
107
+ if not self._configuration.auth_events:
108
+ return DeliveryResult(status=204)
109
+ event = build_sign_in(outcome, **fields)
110
+ if event is None:
111
+ raise ValueError(f"unknown sign-in outcome {outcome!r}")
112
+ payload = sign_in_payload(event, self._configuration)
113
+ except Exception as caught: # noqa: BLE001 — SDK must not raise
114
+ self._log(caught)
115
+ return DeliveryResult(error=caught)
116
+
117
+ if sync or not self._configuration.async_:
118
+ return self._post("logins/web", payload)
119
+
120
+ self._ensure_worker()
121
+ self._queue.put(("logins/web", payload))
122
+ return DeliveryResult(queued=True, status=202)
123
+
101
124
  def deliver(self, notice: Dict[str, Any]) -> DeliveryResult:
102
125
  return self._post("notices", notice)
103
126
 
@@ -39,6 +39,11 @@ class Configuration:
39
39
  filter_keys: tuple = DEFAULT_FILTER_KEYS
40
40
  apm_enabled: bool = False
41
41
  apm_sample_rate: float = 1.0
42
+ # Sign-ins to this app (Security › Logins). Off until you opt in: they
43
+ # carry user names and IPs.
44
+ auth_events: bool = False
45
+ # The app's name in Security › Logins; defaults to the project slug.
46
+ app_name: Optional[str] = field(default_factory=lambda: os.environ.get("ERRORGAP_APP_NAME"))
42
47
 
43
48
  def validate(self) -> None:
44
49
  if not self.project_slug or not str(self.project_slug).strip():
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+ from . import notify
8
+ from .apm import SpanCollector, Transaction, browser_trace_id
9
+ from .transaction_context import new_transaction_id, transaction_scope
10
+
11
+ _TRANSACTION_ATTR = "errorgap_transaction"
12
+
13
+
14
+ class ErrorgapMiddleware:
15
+ """Django middleware that reports unhandled exceptions to Errorgap and
16
+ times each request as an APM transaction (sent with ``apm_enabled``),
17
+ grouped by its URL pattern. Errors raised during the request carry its
18
+ transaction id, and the browser SDK's ``x-errorgap-trace`` header links
19
+ the browser's view of the call to it.
20
+
21
+ Add ``"errorgap.django.ErrorgapMiddleware"`` to ``MIDDLEWARE``. Place it
22
+ early so it sees exceptions raised by inner middleware too.
23
+ """
24
+
25
+ def __init__(self, get_response: Callable[..., Any]):
26
+ self.get_response = get_response
27
+ install_auth_signals()
28
+
29
+ def __call__(self, request: Any) -> Any:
30
+ meta = getattr(request, "META", {}) or {}
31
+ txn = Transaction(
32
+ kind="web",
33
+ id=new_transaction_id(),
34
+ trace_id=browser_trace_id(meta.get("HTTP_X_ERRORGAP_TRACE")),
35
+ method=getattr(request, "method", None),
36
+ path_raw=getattr(request, "path", None) or "/",
37
+ occurred_at=datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
38
+ )
39
+ try:
40
+ setattr(request, _TRANSACTION_ATTR, txn)
41
+ except Exception: # noqa: BLE001
42
+ pass
43
+ start = time.perf_counter()
44
+ response = None
45
+ try:
46
+ with transaction_scope(txn.id or new_transaction_id()):
47
+ response = self.get_response(request)
48
+ return response
49
+ finally:
50
+ txn.status_code = getattr(response, "status_code", None) or 500
51
+ txn.duration_ms = (time.perf_counter() - start) * 1000.0
52
+ txn.path = _route(request) or txn.path_raw
53
+ _send_transaction(txn)
54
+
55
+ def process_exception(self, request: Any, exception: BaseException) -> Optional[Any]:
56
+ notify(
57
+ exception,
58
+ sync=True,
59
+ context=_context(request),
60
+ environment=_environment(request),
61
+ session=_session(request),
62
+ params=_params(request),
63
+ )
64
+ # Returning None lets Django continue its exception handling chain.
65
+ return None
66
+
67
+
68
+ def spans(request: Any) -> Optional[SpanCollector]:
69
+ """The span collector for ``request``'s transaction, for recording DB and
70
+ outbound HTTP spans from views."""
71
+ txn = getattr(request, _TRANSACTION_ATTR, None)
72
+ return txn.spans if txn is not None else None
73
+
74
+
75
+ def _route(request: Any) -> Optional[str]:
76
+ """The matched URL pattern (``/orders/<int:id>``), when one matched."""
77
+ match = getattr(request, "resolver_match", None)
78
+ route = getattr(match, "route", None) if match is not None else None
79
+ if not route:
80
+ return None
81
+ return route if route.startswith("/") else "/" + route
82
+
83
+
84
+ def _send_transaction(txn: Transaction) -> None:
85
+ import errorgap
86
+
87
+ errorgap.notify_transaction(txn)
88
+
89
+
90
+ def _context(request: Any) -> Dict[str, Any]:
91
+ try:
92
+ resolver = getattr(request, "resolver_match", None)
93
+ view_name = resolver.view_name if resolver else None
94
+ except Exception: # noqa: BLE001
95
+ view_name = None
96
+ try:
97
+ url = request.build_absolute_uri()
98
+ except Exception: # noqa: BLE001
99
+ url = getattr(request, "path", None)
100
+ return {
101
+ "url": url,
102
+ "component": view_name or getattr(request, "path", None),
103
+ "action": getattr(request, "method", None),
104
+ }
105
+
106
+
107
+ def _environment(request: Any) -> Dict[str, Any]:
108
+ meta = getattr(request, "META", {}) or {}
109
+ return {
110
+ "method": getattr(request, "method", None),
111
+ "path": getattr(request, "path", None),
112
+ "query_string": meta.get("QUERY_STRING"),
113
+ "user_agent": meta.get("HTTP_USER_AGENT"),
114
+ "remote_addr": meta.get("REMOTE_ADDR"),
115
+ }
116
+
117
+
118
+ def _session(request: Any) -> Dict[str, Any]:
119
+ try:
120
+ session = getattr(request, "session", None)
121
+ if session is None:
122
+ return {}
123
+ return dict(session.items())
124
+ except Exception: # noqa: BLE001
125
+ return {}
126
+
127
+
128
+ def _params(request: Any) -> Dict[str, Any]:
129
+ out: Dict[str, Any] = {}
130
+ try:
131
+ for key, value in (getattr(request, "GET", {}) or {}).items():
132
+ out[key] = value
133
+ post = getattr(request, "POST", None)
134
+ if post:
135
+ for key, value in post.items():
136
+ out[key] = value
137
+ except Exception: # noqa: BLE001
138
+ return out
139
+ return out
140
+
141
+
142
+ def _on_logged_in(sender: Any, request: Any = None, user: Any = None, **_kwargs: Any) -> None:
143
+ from . import configuration, sign_in
144
+
145
+ if request is None or not configuration().auth_events:
146
+ return
147
+ name = user.get_username() if hasattr(user, "get_username") else getattr(user, "pk", None)
148
+ sign_in("success", user=name, request=request)
149
+
150
+
151
+ def _on_login_failed(sender: Any, credentials: Any = None, request: Any = None, **_kwargs: Any) -> None:
152
+ from . import configuration, sign_in
153
+
154
+ # No request: a failed check outside a web request (a shell, a test).
155
+ if request is None or not configuration().auth_events:
156
+ return
157
+ creds = credentials or {}
158
+ try:
159
+ from django.contrib.auth import get_user_model
160
+
161
+ field = get_user_model().USERNAME_FIELD
162
+ except Exception: # noqa: BLE001 — auth app not installed
163
+ field = "username"
164
+ # Django masks the password in `credentials`; only the name is read.
165
+ name = next((creds[k] for k in (field, "username", "email") if isinstance(creds.get(k), str)), None)
166
+ sign_in("failure", user=name, request=request)
167
+
168
+
169
+ def install_auth_signals() -> None:
170
+ """Report Django's login signals (``user_logged_in``,
171
+ ``user_login_failed``) as sign-ins. The middleware calls this; it checks
172
+ ``auth_events`` per event, so ``init`` may run before or after."""
173
+ try:
174
+ from django.contrib.auth.signals import user_logged_in, user_login_failed
175
+ except Exception: # noqa: BLE001 — django.contrib.auth not available
176
+ return
177
+ user_logged_in.connect(_on_logged_in, dispatch_uid="errorgap.sign_in.success")
178
+ user_login_failed.connect(_on_login_failed, dispatch_uid="errorgap.sign_in.failure")
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+ from . import notify
8
+ from .apm import SpanCollector, Transaction, browser_trace_id
9
+ from .transaction_context import new_transaction_id, transaction_scope
10
+
11
+ #: ASGI scope key holding the request's :class:`~errorgap.apm.Transaction`.
12
+ TRANSACTION_KEY = "errorgap.transaction"
13
+
14
+
15
+ class ErrorgapMiddleware:
16
+ """Pure ASGI middleware that reports unhandled exceptions to Errorgap and
17
+ times each request as an APM transaction (sent with ``apm_enabled``),
18
+ grouped by its route path (``/orders/{order_id}``). Errors raised during
19
+ the request carry its transaction id, and the browser SDK's
20
+ ``x-errorgap-trace`` header links the browser's view of the call to it.
21
+
22
+ Usage::
23
+
24
+ from fastapi import FastAPI
25
+ from errorgap.fastapi import ErrorgapMiddleware
26
+
27
+ app = FastAPI()
28
+ app.add_middleware(ErrorgapMiddleware)
29
+ """
30
+
31
+ def __init__(self, app: Callable[..., Any]) -> None:
32
+ self.app = app
33
+
34
+ async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
35
+ if scope.get("type") != "http":
36
+ await self.app(scope, receive, send)
37
+ return
38
+
39
+ headers = dict(scope.get("headers", []))
40
+ txn = Transaction(
41
+ kind="web",
42
+ id=new_transaction_id(),
43
+ trace_id=browser_trace_id(headers.get(b"x-errorgap-trace")),
44
+ method=scope.get("method"),
45
+ path_raw=scope.get("path") or "/",
46
+ occurred_at=datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
47
+ )
48
+ scope[TRANSACTION_KEY] = txn
49
+ start = time.perf_counter()
50
+
51
+ async def _send(message: Dict[str, Any]) -> None:
52
+ if message.get("type") == "http.response.start":
53
+ txn.status_code = message.get("status")
54
+ await send(message)
55
+
56
+ try:
57
+ with transaction_scope(txn.id or new_transaction_id()):
58
+ try:
59
+ await self.app(scope, receive, _send)
60
+ except Exception as exc:
61
+ txn.status_code = 500
62
+ notify(
63
+ exc,
64
+ sync=True,
65
+ context=_context(scope),
66
+ environment=_environment(scope),
67
+ )
68
+ raise
69
+ finally:
70
+ import errorgap
71
+
72
+ txn.duration_ms = (time.perf_counter() - start) * 1000.0
73
+ txn.path = _route(scope) or txn.path_raw
74
+ if txn.status_code is None:
75
+ txn.status_code = 500
76
+ errorgap.notify_transaction(txn)
77
+
78
+
79
+ def spans(request: Any) -> Optional[SpanCollector]:
80
+ """The span collector for the request's transaction (a Starlette/FastAPI
81
+ ``Request``), for recording DB and outbound HTTP spans from endpoints."""
82
+ txn = getattr(request, "scope", {}).get(TRANSACTION_KEY)
83
+ return txn.spans if txn is not None else None
84
+
85
+
86
+ def _route(scope: Dict[str, Any]) -> Optional[str]:
87
+ """The matched route's path template, which FastAPI's router records."""
88
+ route = scope.get("route")
89
+ path = getattr(route, "path", None)
90
+ return path if isinstance(path, str) and path else None
91
+
92
+
93
+ def _context(scope: Dict[str, Any]) -> Dict[str, Any]:
94
+ return {
95
+ "url": _build_url(scope),
96
+ "component": scope.get("path"),
97
+ "action": scope.get("method"),
98
+ }
99
+
100
+
101
+ def _environment(scope: Dict[str, Any]) -> Dict[str, Any]:
102
+ headers = {k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", [])}
103
+ client = scope.get("client") or (None, None)
104
+ return {
105
+ "method": scope.get("method"),
106
+ "path": scope.get("path"),
107
+ "query_string": scope.get("query_string", b"").decode("utf-8", errors="replace"),
108
+ "user_agent": headers.get("user-agent"),
109
+ "remote_addr": client[0],
110
+ }
111
+
112
+
113
+ def _build_url(scope: Dict[str, Any]) -> str:
114
+ scheme = scope.get("scheme", "http")
115
+ headers = dict(scope.get("headers", []))
116
+ host = headers.get(b"host", b"").decode("latin1") or "localhost"
117
+ path = scope.get("path", "/")
118
+ query = scope.get("query_string", b"").decode("utf-8", errors="replace")
119
+ suffix = f"?{query}" if query else ""
120
+ return f"{scheme}://{host}{path}{suffix}"
@@ -0,0 +1,85 @@
1
+ """Sign-ins to this app, shown beside SSH logins in errorgap's
2
+ Security › Logins. Only who, from where and the result are sent: never
3
+ passwords, tokens or session ids."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, Optional
9
+
10
+ from .configuration import Configuration
11
+ from .version import VERSION
12
+
13
+ OUTCOMES = ("success", "failure", "password_reset", "mfa_failure", "locked")
14
+
15
+
16
+ def _request_fields(request: Any) -> Dict[str, Optional[str]]:
17
+ """IP, user agent and path from a Django, Flask/Werkzeug or Starlette
18
+ request. Paths never include the query string."""
19
+ meta = getattr(request, "META", None)
20
+ if isinstance(meta, dict): # Django
21
+ return {
22
+ "ip": meta.get("REMOTE_ADDR"),
23
+ "user_agent": meta.get("HTTP_USER_AGENT"),
24
+ "path": f"{request.method} {request.path}",
25
+ }
26
+ client = getattr(request, "client", None)
27
+ url = getattr(request, "url", None)
28
+ if client is not None and hasattr(url, "path"): # Starlette / FastAPI
29
+ return {
30
+ "ip": getattr(client, "host", None),
31
+ "user_agent": request.headers.get("user-agent"),
32
+ "path": f"{request.method} {url.path}",
33
+ }
34
+ headers = getattr(request, "headers", None) or {}
35
+ return { # Flask / Werkzeug
36
+ "ip": getattr(request, "remote_addr", None),
37
+ "user_agent": headers.get("User-Agent") if hasattr(headers, "get") else None,
38
+ "path": f"{getattr(request, 'method', '')} {getattr(request, 'path', '')}".strip() or None,
39
+ }
40
+
41
+
42
+ def build_sign_in(
43
+ outcome: str,
44
+ user: Any = None,
45
+ request: Any = None,
46
+ ip: Optional[str] = None,
47
+ user_agent: Optional[str] = None,
48
+ path: Optional[str] = None,
49
+ method: Optional[str] = None,
50
+ occurred_at: Optional[datetime] = None,
51
+ ) -> Optional[Dict[str, Any]]:
52
+ """The event to send, or ``None`` for an unknown outcome."""
53
+ if outcome not in OUTCOMES:
54
+ return None
55
+ if request is not None:
56
+ found = _request_fields(request)
57
+ ip = ip or found["ip"]
58
+ user_agent = user_agent or found["user_agent"]
59
+ path = path or found["path"]
60
+ at = (occurred_at or datetime.now(timezone.utc)).astimezone(timezone.utc)
61
+ event: Dict[str, Any] = {
62
+ "occurred_at": at.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
63
+ "outcome": outcome,
64
+ }
65
+ name = "" if user is None else str(user).strip()
66
+ if name:
67
+ event["user"] = name
68
+ if ip:
69
+ event["ip"] = str(ip)
70
+ if user_agent:
71
+ event["user_agent"] = str(user_agent)[:512]
72
+ if path:
73
+ event["path"] = str(path)[:200]
74
+ if method:
75
+ event["method"] = str(method)
76
+ return event
77
+
78
+
79
+ def sign_in_payload(event: Dict[str, Any], configuration: Configuration) -> Dict[str, Any]:
80
+ return {
81
+ "app": configuration.app_name or configuration.project_slug,
82
+ "environment": configuration.environment,
83
+ "sdk": f"errorgap-python {VERSION}",
84
+ "events": [event],
85
+ }
@@ -0,0 +1 @@
1
+ VERSION = "0.4.0"
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ django = pytest.importorskip("django")
6
+
7
+ from django.conf import settings
8
+ from django.http import HttpResponse
9
+ from django.test import RequestFactory
10
+ from django.urls import path
11
+
12
+ import errorgap
13
+ from errorgap.django import ErrorgapMiddleware
14
+
15
+
16
+ @pytest.fixture(scope="module", autouse=True)
17
+ def _django_setup():
18
+ if not settings.configured:
19
+ settings.configure(
20
+ DEBUG=False,
21
+ ALLOWED_HOSTS=["*"],
22
+ ROOT_URLCONF=__name__,
23
+ DATABASES={},
24
+ INSTALLED_APPS=[],
25
+ MIDDLEWARE=[],
26
+ SECRET_KEY="test",
27
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
28
+ )
29
+ import django as _django
30
+
31
+ _django.setup()
32
+
33
+
34
+ def _boom(_request):
35
+ raise RuntimeError("django-boom")
36
+
37
+
38
+ urlpatterns = [path("boom", _boom)]
39
+
40
+
41
+ def test_middleware_reports_exception(ingestor, reset_errorgap):
42
+ errorgap.init(
43
+ endpoint=ingestor.endpoint,
44
+ project_slug="demo",
45
+ api_key="flk_test",
46
+ async_=False,
47
+ capture_globals=False,
48
+ )
49
+
50
+ factory = RequestFactory()
51
+ request = factory.get("/boom?x=1")
52
+
53
+ middleware = ErrorgapMiddleware(get_response=lambda req: HttpResponse(""))
54
+
55
+ try:
56
+ _boom(request)
57
+ except RuntimeError as exc:
58
+ middleware.process_exception(request, exc)
59
+
60
+ errorgap.flush(timeout=5)
61
+ assert len(ingestor.requests) == 1
62
+ body = ingestor.requests[0].body
63
+ assert body["errors"][0]["type"] == "RuntimeError"
64
+ assert body["errors"][0]["message"] == "django-boom"
65
+ assert body["context"]["action"] == "GET"
66
+
67
+
68
+ def _order(request, order_id):
69
+ from errorgap.django import spans
70
+
71
+ spans(request).database("SELECT * FROM orders WHERE id = %d" % order_id, 1.0)
72
+ errorgap.notify(RuntimeError("card declined"))
73
+ return HttpResponse("ok", status=201)
74
+
75
+
76
+ urlpatterns.append(path("orders/<int:order_id>", _order))
77
+
78
+
79
+ def test_requests_are_transactions_linked_to_their_errors(ingestor, reset_errorgap):
80
+ from django.test import Client
81
+
82
+ errorgap.init(
83
+ endpoint=ingestor.endpoint,
84
+ project_slug="demo",
85
+ async_=False,
86
+ capture_globals=False,
87
+ apm_enabled=True,
88
+ )
89
+ settings.MIDDLEWARE = ["errorgap.django.ErrorgapMiddleware"]
90
+ try:
91
+ client = Client(raise_request_exception=False)
92
+ ok = client.get("/orders/7", HTTP_X_ERRORGAP_TRACE="0192F3C4-7A1B-4C2D-9E3F-0123456789AB")
93
+ boom = client.get("/boom")
94
+ finally:
95
+ settings.MIDDLEWARE = []
96
+ assert ok.status_code == 201
97
+ assert boom.status_code == 500
98
+
99
+ txns = [r.body for r in ingestor.requests if r.path.endswith("/transactions")]
100
+ notices = [r.body for r in ingestor.requests if r.path.endswith("/notices")]
101
+ ok_txn = next(t for t in txns if t["path_raw"] == "/orders/7")
102
+ boom_txn = next(t for t in txns if t["path_raw"] == "/boom")
103
+ assert ok_txn["path"] == "/orders/<int:order_id>"
104
+ assert ok_txn["status_code"] == 201
105
+ assert ok_txn["trace_id"] == "0192f3c4-7a1b-4c2d-9e3f-0123456789ab"
106
+ assert ok_txn["spans"][0]["sql"] == "SELECT * FROM orders WHERE id = ?"
107
+ assert boom_txn["status_code"] == 500
108
+ assert "trace_id" not in boom_txn
109
+ by_message = {n["errors"][0]["message"]: n["context"].get("transaction_id") for n in notices}
110
+ assert by_message["card declined"] == ok_txn["id"]
111
+ assert by_message["django-boom"] == boom_txn["id"]
112
+
113
+
114
+ def test_login_signals_report_sign_ins(ingestor, reset_errorgap):
115
+ from django.contrib.auth.signals import user_logged_in, user_login_failed
116
+
117
+ errorgap.init(endpoint=ingestor.endpoint, project_slug="demo", api_key="k", async_=False,
118
+ capture_globals=False, auth_events=True)
119
+ ErrorgapMiddleware(get_response=lambda req: HttpResponse("")) # connects the signals
120
+ request = RequestFactory().post("/accounts/login/", REMOTE_ADDR="198.51.100.71",
121
+ HTTP_USER_AGENT="Firefox/131")
122
+
123
+ class User:
124
+ def get_username(self):
125
+ return "mara"
126
+
127
+ user_logged_in.send(sender=User, request=request, user=User())
128
+ # Django masks the password before sending the signal.
129
+ user_login_failed.send(sender=None, request=request,
130
+ credentials={"username": "admin", "password": "********************"})
131
+ # A failed check outside a request is not a sign-in attempt.
132
+ user_login_failed.send(sender=None, request=None, credentials={"username": "cli"})
133
+
134
+ events = [r.body["events"][0] for r in ingestor.requests]
135
+ assert [(e["outcome"], e["user"]) for e in events] == [("success", "mara"), ("failure", "admin")]
136
+ assert events[0]["path"] == "POST /accounts/login/"
137
+ assert events[0]["ip"] == "198.51.100.71"
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ fastapi = pytest.importorskip("fastapi")
6
+
7
+ from fastapi import FastAPI, Request
8
+ from starlette.testclient import TestClient
9
+
10
+ import errorgap
11
+ from errorgap.fastapi import ErrorgapMiddleware, spans
12
+
13
+
14
+ def test_fastapi_middleware_reports_exception(ingestor, reset_errorgap):
15
+ errorgap.init(
16
+ endpoint=ingestor.endpoint,
17
+ project_slug="demo",
18
+ api_key="flk_test",
19
+ async_=False,
20
+ capture_globals=False,
21
+ )
22
+
23
+ app = FastAPI()
24
+ app.add_middleware(ErrorgapMiddleware)
25
+
26
+ @app.get("/boom")
27
+ def boom():
28
+ raise RuntimeError("fastapi-boom")
29
+
30
+ client = TestClient(app, raise_server_exceptions=False)
31
+ response = client.get("/boom?x=1")
32
+ assert response.status_code == 500
33
+
34
+ errorgap.flush(timeout=5)
35
+ assert len(ingestor.requests) == 1
36
+ body = ingestor.requests[0].body
37
+ assert body["errors"][0]["type"] == "RuntimeError"
38
+ assert body["errors"][0]["message"] == "fastapi-boom"
39
+ assert body["context"]["action"] == "GET"
40
+
41
+
42
+ def test_fastapi_requests_are_transactions_linked_to_their_errors(ingestor, reset_errorgap):
43
+ errorgap.init(
44
+ endpoint=ingestor.endpoint,
45
+ project_slug="demo",
46
+ async_=False,
47
+ capture_globals=False,
48
+ apm_enabled=True,
49
+ )
50
+
51
+ app = FastAPI()
52
+ app.add_middleware(ErrorgapMiddleware)
53
+
54
+ @app.get("/orders/{order_id}", status_code=201)
55
+ async def order(order_id: int, request: Request):
56
+ spans(request).database("SELECT * FROM orders WHERE id = %d" % order_id, 1.0)
57
+ errorgap.notify(RuntimeError("card declined"))
58
+ return {"id": order_id}
59
+
60
+ @app.get("/sync/{n}")
61
+ def sync_boom(n: int):
62
+ raise RuntimeError("fastapi-boom")
63
+
64
+ client = TestClient(app, raise_server_exceptions=False)
65
+ ok = client.get("/orders/7", headers={"x-errorgap-trace": "0192F3C4-7A1B-4C2D-9E3F-0123456789AB"})
66
+ boom = client.get("/sync/1")
67
+ assert ok.status_code == 201
68
+ assert boom.status_code == 500
69
+
70
+ txns = [r.body for r in ingestor.requests if r.path.endswith("/transactions")]
71
+ notices = [r.body for r in ingestor.requests if r.path.endswith("/notices")]
72
+ ok_txn = next(t for t in txns if t["path_raw"] == "/orders/7")
73
+ boom_txn = next(t for t in txns if t["path_raw"] == "/sync/1")
74
+ assert ok_txn["path"] == "/orders/{order_id}"
75
+ assert ok_txn["status_code"] == 201
76
+ assert ok_txn["trace_id"] == "0192f3c4-7a1b-4c2d-9e3f-0123456789ab"
77
+ assert ok_txn["spans"][0]["sql"] == "SELECT * FROM orders WHERE id = ?"
78
+ assert boom_txn["path"] == "/sync/{n}"
79
+ assert boom_txn["status_code"] == 500
80
+ by_message = {n["errors"][0]["message"]: n["context"].get("transaction_id") for n in notices}
81
+ assert by_message["card declined"] == ok_txn["id"]
82
+ assert by_message["fastapi-boom"] == boom_txn["id"]
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import errorgap
6
+ from errorgap.sign_ins import build_sign_in
7
+
8
+
9
+ def test_flask_like_request_fields():
10
+ req = SimpleNamespace(
11
+ remote_addr="198.51.100.71", headers={"User-Agent": "Safari 18"}, method="POST", path="/login"
12
+ )
13
+ e = build_sign_in("success", user="mara@oxcoffee.com", request=req)
14
+ assert e["user"] == "mara@oxcoffee.com"
15
+ assert (e["ip"], e["user_agent"], e["path"]) == ("198.51.100.71", "Safari 18", "POST /login")
16
+ assert e["occurred_at"].endswith("Z")
17
+
18
+
19
+ def test_starlette_like_request_fields():
20
+ req = SimpleNamespace(
21
+ client=SimpleNamespace(host="203.0.113.9"),
22
+ url=SimpleNamespace(path="/token"),
23
+ headers={"user-agent": "curl/8"},
24
+ method="POST",
25
+ )
26
+ e = build_sign_in("mfa_failure", request=req)
27
+ assert (e["ip"], e["path"], "user" in e) == ("203.0.113.9", "POST /token", False)
28
+
29
+
30
+ def test_unknown_outcome_is_dropped():
31
+ assert build_sign_in("teleported") is None
32
+
33
+
34
+ def test_sign_in_posts_once_opted_in(ingestor, reset_errorgap):
35
+ errorgap.init(endpoint=ingestor.endpoint, project_slug="ox-coffee", api_key="k1",
36
+ environment="production", async_=False, capture_globals=False)
37
+ assert errorgap.sign_in("success", user="x").status == 204
38
+ assert ingestor.requests == []
39
+
40
+ errorgap.init(endpoint=ingestor.endpoint, project_slug="ox-coffee", api_key="k1",
41
+ environment="production", async_=False, capture_globals=False,
42
+ auth_events=True, app_name="oxcoffee-web")
43
+ errorgap.sign_in("locked", user="admin", ip="203.0.113.9")
44
+ req = ingestor.requests[0]
45
+ assert req.path == "/api/projects/ox-coffee/logins/web"
46
+ assert req.headers["x-errorgap-project-key"] == "k1"
47
+ assert req.body["app"] == "oxcoffee-web"
48
+ assert req.body["environment"] == "production"
49
+ assert req.body["sdk"].startswith("errorgap-python ")
50
+ assert req.body["events"][0]["outcome"] == "locked"
@@ -1,83 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any, Callable, Dict, Optional
4
-
5
- from . import notify
6
-
7
-
8
- class ErrorgapMiddleware:
9
- """Django middleware that reports unhandled exceptions to Errorgap.
10
-
11
- Add ``"errorgap.django.ErrorgapMiddleware"`` to ``MIDDLEWARE``. Place it
12
- early so it sees exceptions raised by inner middleware too.
13
- """
14
-
15
- def __init__(self, get_response: Callable[..., Any]):
16
- self.get_response = get_response
17
-
18
- def __call__(self, request: Any) -> Any:
19
- return self.get_response(request)
20
-
21
- def process_exception(self, request: Any, exception: BaseException) -> Optional[Any]:
22
- notify(
23
- exception,
24
- sync=True,
25
- context=_context(request),
26
- environment=_environment(request),
27
- session=_session(request),
28
- params=_params(request),
29
- )
30
- # Returning None lets Django continue its exception handling chain.
31
- return None
32
-
33
-
34
- def _context(request: Any) -> Dict[str, Any]:
35
- try:
36
- resolver = getattr(request, "resolver_match", None)
37
- view_name = resolver.view_name if resolver else None
38
- except Exception: # noqa: BLE001
39
- view_name = None
40
- try:
41
- url = request.build_absolute_uri()
42
- except Exception: # noqa: BLE001
43
- url = getattr(request, "path", None)
44
- return {
45
- "url": url,
46
- "component": view_name or getattr(request, "path", None),
47
- "action": getattr(request, "method", None),
48
- }
49
-
50
-
51
- def _environment(request: Any) -> Dict[str, Any]:
52
- meta = getattr(request, "META", {}) or {}
53
- return {
54
- "method": getattr(request, "method", None),
55
- "path": getattr(request, "path", None),
56
- "query_string": meta.get("QUERY_STRING"),
57
- "user_agent": meta.get("HTTP_USER_AGENT"),
58
- "remote_addr": meta.get("REMOTE_ADDR"),
59
- }
60
-
61
-
62
- def _session(request: Any) -> Dict[str, Any]:
63
- try:
64
- session = getattr(request, "session", None)
65
- if session is None:
66
- return {}
67
- return dict(session.items())
68
- except Exception: # noqa: BLE001
69
- return {}
70
-
71
-
72
- def _params(request: Any) -> Dict[str, Any]:
73
- out: Dict[str, Any] = {}
74
- try:
75
- for key, value in (getattr(request, "GET", {}) or {}).items():
76
- out[key] = value
77
- post = getattr(request, "POST", None)
78
- if post:
79
- for key, value in post.items():
80
- out[key] = value
81
- except Exception: # noqa: BLE001
82
- return out
83
- return out
@@ -1,67 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any, Callable, Dict
4
-
5
- from . import notify
6
-
7
-
8
- class ErrorgapMiddleware:
9
- """Pure ASGI middleware that reports unhandled exceptions to Errorgap.
10
-
11
- Usage::
12
-
13
- from fastapi import FastAPI
14
- from errorgap.fastapi import ErrorgapMiddleware
15
-
16
- app = FastAPI()
17
- app.add_middleware(ErrorgapMiddleware)
18
- """
19
-
20
- def __init__(self, app: Callable[..., Any]) -> None:
21
- self.app = app
22
-
23
- async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
24
- if scope.get("type") != "http":
25
- await self.app(scope, receive, send)
26
- return
27
-
28
- try:
29
- await self.app(scope, receive, send)
30
- except Exception as exc:
31
- notify(
32
- exc,
33
- sync=True,
34
- context=_context(scope),
35
- environment=_environment(scope),
36
- )
37
- raise
38
-
39
-
40
- def _context(scope: Dict[str, Any]) -> Dict[str, Any]:
41
- return {
42
- "url": _build_url(scope),
43
- "component": scope.get("path"),
44
- "action": scope.get("method"),
45
- }
46
-
47
-
48
- def _environment(scope: Dict[str, Any]) -> Dict[str, Any]:
49
- headers = {k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", [])}
50
- client = scope.get("client") or (None, None)
51
- return {
52
- "method": scope.get("method"),
53
- "path": scope.get("path"),
54
- "query_string": scope.get("query_string", b"").decode("utf-8", errors="replace"),
55
- "user_agent": headers.get("user-agent"),
56
- "remote_addr": client[0],
57
- }
58
-
59
-
60
- def _build_url(scope: Dict[str, Any]) -> str:
61
- scheme = scope.get("scheme", "http")
62
- headers = dict(scope.get("headers", []))
63
- host = headers.get(b"host", b"").decode("latin1") or "localhost"
64
- path = scope.get("path", "/")
65
- query = scope.get("query_string", b"").decode("utf-8", errors="replace")
66
- suffix = f"?{query}" if query else ""
67
- return f"{scheme}://{host}{path}{suffix}"
@@ -1 +0,0 @@
1
- VERSION = "0.2.0"
@@ -1,65 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import pytest
4
-
5
- django = pytest.importorskip("django")
6
-
7
- from django.conf import settings
8
- from django.http import HttpResponse
9
- from django.test import RequestFactory
10
- from django.urls import path
11
-
12
- import errorgap
13
- from errorgap.django import ErrorgapMiddleware
14
-
15
-
16
- @pytest.fixture(scope="module", autouse=True)
17
- def _django_setup():
18
- if not settings.configured:
19
- settings.configure(
20
- DEBUG=False,
21
- ALLOWED_HOSTS=["*"],
22
- ROOT_URLCONF=__name__,
23
- DATABASES={},
24
- INSTALLED_APPS=[],
25
- MIDDLEWARE=[],
26
- SECRET_KEY="test",
27
- DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
28
- )
29
- import django as _django
30
-
31
- _django.setup()
32
-
33
-
34
- def _boom(_request):
35
- raise RuntimeError("django-boom")
36
-
37
-
38
- urlpatterns = [path("boom", _boom)]
39
-
40
-
41
- def test_middleware_reports_exception(ingestor, reset_errorgap):
42
- errorgap.init(
43
- endpoint=ingestor.endpoint,
44
- project_slug="demo",
45
- api_key="flk_test",
46
- async_=False,
47
- capture_globals=False,
48
- )
49
-
50
- factory = RequestFactory()
51
- request = factory.get("/boom?x=1")
52
-
53
- middleware = ErrorgapMiddleware(get_response=lambda req: HttpResponse(""))
54
-
55
- try:
56
- _boom(request)
57
- except RuntimeError as exc:
58
- middleware.process_exception(request, exc)
59
-
60
- errorgap.flush(timeout=5)
61
- assert len(ingestor.requests) == 1
62
- body = ingestor.requests[0].body
63
- assert body["errors"][0]["type"] == "RuntimeError"
64
- assert body["errors"][0]["message"] == "django-boom"
65
- assert body["context"]["action"] == "GET"
@@ -1,39 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import pytest
4
-
5
- fastapi = pytest.importorskip("fastapi")
6
-
7
- from fastapi import FastAPI
8
- from starlette.testclient import TestClient
9
-
10
- import errorgap
11
- from errorgap.fastapi import ErrorgapMiddleware
12
-
13
-
14
- def test_fastapi_middleware_reports_exception(ingestor, reset_errorgap):
15
- errorgap.init(
16
- endpoint=ingestor.endpoint,
17
- project_slug="demo",
18
- api_key="flk_test",
19
- async_=False,
20
- capture_globals=False,
21
- )
22
-
23
- app = FastAPI()
24
- app.add_middleware(ErrorgapMiddleware)
25
-
26
- @app.get("/boom")
27
- def boom():
28
- raise RuntimeError("fastapi-boom")
29
-
30
- client = TestClient(app, raise_server_exceptions=False)
31
- response = client.get("/boom?x=1")
32
- assert response.status_code == 500
33
-
34
- errorgap.flush(timeout=5)
35
- assert len(ingestor.requests) == 1
36
- body = ingestor.requests[0].body
37
- assert body["errors"][0]["type"] == "RuntimeError"
38
- assert body["errors"][0]["message"] == "fastapi-boom"
39
- assert body["context"]["action"] == "GET"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes