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.
- {errorgap-0.2.0 → errorgap-0.4.0}/CHANGELOG.md +17 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/PKG-INFO +42 -3
- {errorgap-0.2.0 → errorgap-0.4.0}/README.md +41 -2
- {errorgap-0.2.0 → errorgap-0.4.0}/pyproject.toml +1 -1
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/__init__.py +28 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/client.py +23 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/configuration.py +5 -0
- errorgap-0.4.0/src/errorgap/django.py +178 -0
- errorgap-0.4.0/src/errorgap/fastapi.py +120 -0
- errorgap-0.4.0/src/errorgap/sign_ins.py +85 -0
- errorgap-0.4.0/src/errorgap/version.py +1 -0
- errorgap-0.4.0/tests/test_django.py +137 -0
- errorgap-0.4.0/tests/test_fastapi.py +82 -0
- errorgap-0.4.0/tests/test_sign_ins.py +50 -0
- errorgap-0.2.0/src/errorgap/django.py +0 -83
- errorgap-0.2.0/src/errorgap/fastapi.py +0 -67
- errorgap-0.2.0/src/errorgap/version.py +0 -1
- errorgap-0.2.0/tests/test_django.py +0 -65
- errorgap-0.2.0/tests/test_fastapi.py +0 -39
- {errorgap-0.2.0 → errorgap-0.4.0}/.github/workflows/ci.yml +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/.github/workflows/release.yml +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/.gitignore +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/LICENSE +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/apm.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/backtrace.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/filter.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/flask.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/handlers.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/notice.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/transaction_context.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/src/errorgap/wsgi.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/conftest.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_apm.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_backtrace.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_client.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_configuration.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_filter.py +0 -0
- {errorgap-0.2.0 → errorgap-0.4.0}/tests/test_flask.py +0 -0
- {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.
|
|
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
|
|
159
|
-
`init_app`)
|
|
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
|
|
123
|
-
`init_app`)
|
|
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
|
|
@@ -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
|
|
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
|