devbench 0.1.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.
- devbench-0.1.0/LICENSE +21 -0
- devbench-0.1.0/PKG-INFO +259 -0
- devbench-0.1.0/README.md +240 -0
- devbench-0.1.0/devbench/__init__.py +121 -0
- devbench-0.1.0/devbench/__main__.py +24 -0
- devbench-0.1.0/devbench/_core.py +252 -0
- devbench-0.1.0/devbench/_integration.py +107 -0
- devbench-0.1.0/devbench/asgi.py +82 -0
- devbench-0.1.0/devbench/celery.py +152 -0
- devbench-0.1.0/devbench/config.py +189 -0
- devbench-0.1.0/devbench/context.py +94 -0
- devbench-0.1.0/devbench/direct.py +639 -0
- devbench-0.1.0/devbench/django/__init__.py +12 -0
- devbench-0.1.0/devbench/django/apps.py +68 -0
- devbench-0.1.0/devbench/django/management/__init__.py +0 -0
- devbench-0.1.0/devbench/django/management/commands/__init__.py +0 -0
- devbench-0.1.0/devbench/django/management/commands/devbench_test.py +18 -0
- devbench-0.1.0/devbench/django/middleware.py +87 -0
- devbench-0.1.0/devbench/egress_policy.py +101 -0
- devbench-0.1.0/devbench/fastapi.py +14 -0
- devbench-0.1.0/devbench/fingerprint.py +199 -0
- devbench-0.1.0/devbench/flask.py +54 -0
- devbench-0.1.0/devbench/identity.py +50 -0
- devbench-0.1.0/devbench/logs.py +282 -0
- devbench-0.1.0/devbench/py.typed +0 -0
- devbench-0.1.0/devbench/reporter.py +233 -0
- devbench-0.1.0/devbench/scrub.py +247 -0
- devbench-0.1.0/devbench/trace.py +82 -0
- devbench-0.1.0/devbench/version.py +12 -0
- devbench-0.1.0/devbench/wsgi.py +67 -0
- devbench-0.1.0/devbench.egg-info/PKG-INFO +259 -0
- devbench-0.1.0/devbench.egg-info/SOURCES.txt +43 -0
- devbench-0.1.0/devbench.egg-info/dependency_links.txt +1 -0
- devbench-0.1.0/devbench.egg-info/top_level.txt +1 -0
- devbench-0.1.0/pyproject.toml +46 -0
- devbench-0.1.0/setup.cfg +4 -0
- devbench-0.1.0/tests/test_celery.py +117 -0
- devbench-0.1.0/tests/test_config.py +245 -0
- devbench-0.1.0/tests/test_direct.py +305 -0
- devbench-0.1.0/tests/test_django.py +136 -0
- devbench-0.1.0/tests/test_fastapi.py +119 -0
- devbench-0.1.0/tests/test_flask.py +117 -0
- devbench-0.1.0/tests/test_logs.py +360 -0
- devbench-0.1.0/tests/test_reporter.py +166 -0
- devbench-0.1.0/tests/test_vectors.py +364 -0
devbench-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RedpointRack LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
devbench-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devbench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dev Bench server SDK for Python: errors, who was affected, and the server log lines of a failing request, for Django, FastAPI, Flask and Celery.
|
|
5
|
+
Author: Dev Bench
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://developerbench.com
|
|
8
|
+
Project-URL: Source, https://github.com/pasperry/devbench-sdk
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Classifier: Framework :: Celery
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Dev Bench for Python
|
|
21
|
+
|
|
22
|
+
Errors, who they hit, and the server log lines of the request that failed,
|
|
23
|
+
from Django, FastAPI, Flask and Celery to Dev Bench. Zero runtime
|
|
24
|
+
dependencies (the standard library only); Python 3.9+.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install devbench
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Set **one** value in the app's environment, the DSN `adt dsn create`
|
|
33
|
+
printed for this environment:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
DEVBENCH_DSN=https://<public>:<secret>@adt-ingest.onrender.com
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then add one line where your framework wants it.
|
|
40
|
+
|
|
41
|
+
### Django
|
|
42
|
+
|
|
43
|
+
In `settings.py`:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
INSTALLED_APPS = [
|
|
47
|
+
"devbench.django",
|
|
48
|
+
# ...
|
|
49
|
+
]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That is the whole install: at startup it puts its middleware first in
|
|
53
|
+
`MIDDLEWARE` (not again if you list `devbench.django.middleware.DevbenchMiddleware`
|
|
54
|
+
yourself), reports what Django answers with a 500, keeps log lines, and,
|
|
55
|
+
when Celery is loaded, installs the Celery hooks. WSGI and ASGI. Django 4.2+.
|
|
56
|
+
|
|
57
|
+
### FastAPI (and Starlette)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from fastapi import FastAPI
|
|
61
|
+
from devbench.fastapi import DevbenchMiddleware
|
|
62
|
+
|
|
63
|
+
app = FastAPI()
|
|
64
|
+
app.add_middleware(DevbenchMiddleware)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Async and sync endpoints alike. Any other ASGI app: `devbench.asgi.DevbenchMiddleware`.
|
|
68
|
+
|
|
69
|
+
### Flask
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from flask import Flask
|
|
73
|
+
from devbench.flask import Devbench
|
|
74
|
+
|
|
75
|
+
app = Flask(__name__)
|
|
76
|
+
Devbench(app) # or: Devbench().init_app(app)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Any other WSGI app: `application = devbench.wsgi.DevbenchMiddleware(application)`.
|
|
80
|
+
|
|
81
|
+
### Celery
|
|
82
|
+
|
|
83
|
+
Under Django there is nothing to add. Otherwise, where your Celery app is
|
|
84
|
+
created:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import devbench.celery
|
|
88
|
+
devbench.celery.install()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Check it
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
python -m devbench test # Django: python manage.py devbench_test
|
|
95
|
+
# Dev Bench: checking the DSN against https://adt-ingest.onrender.com (service "acme_shop")
|
|
96
|
+
# HTTP 200: accepted - tenant "acme", environment "production". Nothing was recorded; real errors will appear as issues.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
It prints the HTTP result, or says plainly what is wrong (`DEVBENCH_DSN is
|
|
100
|
+
not set`, `HTTP 401: the key in DEVBENCH_DSN was rejected`, `could not reach
|
|
101
|
+
…`) and exits non-zero. It records nothing.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
All optional except the DSN.
|
|
106
|
+
|
|
107
|
+
| Variable | Default | |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `DEVBENCH_DSN` | — (falls back to `ADT_DSN`) | `https://<public>:<secret>@<host>[:port]` from `adt dsn create`. The server authenticates with the secret. Plain `http://` is accepted only for localhost. Unset: nothing is sent (there is no sidecar mode). |
|
|
110
|
+
| `DEVBENCH_SERVICE` | Django: the project (`acme_shop` from `acme_shop.settings`); Flask: the app's import name; else `app`; **`<that>-celery` in a Celery worker** | Groups this app's issues. Set it and it wins everywhere. |
|
|
111
|
+
| `DEVBENCH_RELEASE` | the first found of: a `REVISION` file in the app root (Django's `BASE_DIR`, else the working directory), `HEROKU_SLUG_COMMIT`, `KAMAL_VERSION`, `GITHUB_SHA`, `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`; else empty | Which deploy an occurrence came from. |
|
|
112
|
+
| `DEVBENCH_ENABLED` | on | `false` turns everything off: no hooks, no headers, nothing sent. |
|
|
113
|
+
|
|
114
|
+
Or from code, before the first report:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import devbench
|
|
118
|
+
devbench.configure(dsn=secrets["devbench_dsn"], service="billing-api")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A DSN that does not parse logs one `[devbench]` warning (on the `devbench`
|
|
122
|
+
logger) and reports nothing; it never raises into the app. Neither key is
|
|
123
|
+
ever printed.
|
|
124
|
+
|
|
125
|
+
## What is captured automatically
|
|
126
|
+
|
|
127
|
+
| What | How | `context` |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| A Django view or middleware error answered with a 500 | `got_request_exception` | `request` |
|
|
130
|
+
| A Flask view error answered with a 500 | `got_request_exception` | `request` |
|
|
131
|
+
| An exception escaping a FastAPI/Starlette app (still answered 500 by Starlette, and your exception handlers still run) | the ASGI middleware reports, then re-raises the same object | `request` |
|
|
132
|
+
| A failed Celery task (the last attempt; `self.retry()` is not a failure) | `task_failure` | `job` |
|
|
133
|
+
|
|
134
|
+
Each report carries the class (`module.QualName`; builtins plainly, e.g.
|
|
135
|
+
`KeyError`), the message (≤ 2,000 characters), where it surfaced (the view,
|
|
136
|
+
endpoint or task: `shop.views.checkout`, `acme.charge`), up to 50 stack
|
|
137
|
+
frames (innermost first, paths relative to the app root), the trace, and
|
|
138
|
+
the user.
|
|
139
|
+
|
|
140
|
+
- **Once per exception.** An error that passes several hooks is reported
|
|
141
|
+
once.
|
|
142
|
+
- **Status codes are not failures.** Django's `Http404`, `PermissionDenied`,
|
|
143
|
+
`BadRequest`, `SuspiciousOperation`; werkzeug and Starlette
|
|
144
|
+
`HTTPException`s; Celery's `Retry`, `Ignore`, `Reject`; and anything that
|
|
145
|
+
is not an `Exception` (`SystemExit`, `KeyboardInterrupt`,
|
|
146
|
+
`asyncio.CancelledError`) are never reported. Add your own by name:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
devbench.ignore_exceptions().append("myapp.errors.NotAuthorized")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- **Never in the way.** No hook raises into the app; a failure while
|
|
153
|
+
reporting is dropped, and the application's own exception always comes
|
|
154
|
+
out unchanged.
|
|
155
|
+
|
|
156
|
+
## What is sent, and when
|
|
157
|
+
|
|
158
|
+
- Each report is **fingerprinted and counted in the process** (class,
|
|
159
|
+
templated message and in-app frames; the same algorithm as every other
|
|
160
|
+
Dev Bench SDK). Repeats cost a counter increment.
|
|
161
|
+
- **Once a minute** a background thread sends the counts (fingerprint, how
|
|
162
|
+
many, first/last seen, up to 20 affected users each) in one small request.
|
|
163
|
+
Nothing is sent for a quiet minute (except a small poll while the process
|
|
164
|
+
holds log lines, below), and what is left is sent when the process exits
|
|
165
|
+
(waiting at most 2 seconds; a Celery prefork child flushes at shutdown).
|
|
166
|
+
- **Detail only on request.** When Dev Bench sees a fingerprint for the
|
|
167
|
+
first time it asks for evidence, and the SDK uploads the stack and one
|
|
168
|
+
example message, **templated and scrubbed first** (emails, numbers, quoted
|
|
169
|
+
values, tokens, card numbers, credentials and two-word names are removed).
|
|
170
|
+
Who was affected never goes into evidence.
|
|
171
|
+
- **Never harms the app.** No network I/O on a request thread; 5-second
|
|
172
|
+
timeouts on the background thread, one retry, then the data is dropped;
|
|
173
|
+
memory is bounded (512 distinct problems per minute, the rest only
|
|
174
|
+
counted). Safe under forking servers (gunicorn, uWSGI, Celery prefork):
|
|
175
|
+
each process reports on its own.
|
|
176
|
+
|
|
177
|
+
## Server log lines
|
|
178
|
+
|
|
179
|
+
Triage reads the server's log lines for the user action that failed. The
|
|
180
|
+
SDK keeps them itself, no sidecar:
|
|
181
|
+
|
|
182
|
+
- **What:** every record that reaches the root logger's handlers while a
|
|
183
|
+
request or task carries a trace (`x-adt-trace` from the browser sensor; a
|
|
184
|
+
Celery task inherits the trace of the request that enqueued it). It sees
|
|
185
|
+
exactly what your logging configuration already lets through: it never
|
|
186
|
+
turns on a level you have off, writes nothing anywhere, and leaves your
|
|
187
|
+
handlers unchanged. A logger with `propagate = False` is not seen. An
|
|
188
|
+
exception's traceback is held one line each. Untraced records are not
|
|
189
|
+
kept.
|
|
190
|
+
- **Bounded:** per process, the newest 10,000 lines or 4 MiB, nothing older
|
|
191
|
+
than 15 minutes, 4 KiB per line. A logging call never waits on I/O and
|
|
192
|
+
never raises because of this.
|
|
193
|
+
- **Sent only when asked:** when Dev Bench asks for a trace, the flush
|
|
194
|
+
thread sends that trace's lines from this process (the newest ≤ 200 lines
|
|
195
|
+
/ 64 KiB), **redacted first** with the same strict rules as evidence, plus
|
|
196
|
+
any redaction rules Dev Bench has learned for your app.
|
|
197
|
+
- **Several processes:** each gunicorn worker and Celery process keeps and
|
|
198
|
+
answers for its own lines (a task's lines are in the worker, under the
|
|
199
|
+
trace of the request that enqueued it); Dev Bench assembles them.
|
|
200
|
+
|
|
201
|
+
A plain WSGI/ASGI app without one of the integrations: `devbench.capture_logs()`.
|
|
202
|
+
|
|
203
|
+
## Who was affected
|
|
204
|
+
|
|
205
|
+
Set it once per request, after your authentication has run (a middleware
|
|
206
|
+
after Django's `AuthenticationMiddleware`, a FastAPI dependency, a Flask
|
|
207
|
+
`before_request`):
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
devbench.set_user(email=request.user.email, account=request.user.account_id)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `email` is trimmed and lowercased; `account` is your own tenant/customer
|
|
214
|
+
id, sent as a string. Both optional, each truncated to 255 characters.
|
|
215
|
+
- It is sent in its own field with the counts, never inside a message or
|
|
216
|
+
evidence.
|
|
217
|
+
- It ends with the request (a Celery task: with the task), so the next
|
|
218
|
+
request on the same thread never inherits it.
|
|
219
|
+
|
|
220
|
+
## Reporting what you caught
|
|
221
|
+
|
|
222
|
+
An exception you caught but want seen, like Sentry's `capture_exception`:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
except requests.RequestException as e:
|
|
226
|
+
devbench.capture_exception(e, symbol="billing.sync")
|
|
227
|
+
retry_later()
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
A failure you handled and turned into a normal response, the case this
|
|
231
|
+
product exists for:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
except IntegrityError as e:
|
|
235
|
+
devbench.report_handled(e, symbol="customers.views.update", reason="uniqueness")
|
|
236
|
+
return JsonResponse({"ok": True}) # <- the user is about to be told this worked
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`report_handled` also marks the response with `x-adt-handled: <count>` (and
|
|
240
|
+
lists it in `Access-Control-Expose-Headers`, appending to what is there).
|
|
241
|
+
The browser already knows whether the user was shown an error; this is the
|
|
242
|
+
one fact it cannot know on its own. The header carries a count and nothing
|
|
243
|
+
else, and it is added even when reporting is down.
|
|
244
|
+
|
|
245
|
+
## Trace propagation
|
|
246
|
+
|
|
247
|
+
The correlation id the browser sent (`x-adt-trace`) is `devbench.current_trace()`
|
|
248
|
+
for the duration of the request. Forward it on outbound calls to other
|
|
249
|
+
instrumented services:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
requests.post(url, json=body, headers=devbench.trace_headers())
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Not in this release
|
|
256
|
+
|
|
257
|
+
- No sidecar transport: without a DSN nothing is sent.
|
|
258
|
+
- `devbench.track` (custom events) and the chat widget's signed identity
|
|
259
|
+
are in the Ruby and Go SDKs; they come to Python later.
|
devbench-0.1.0/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Dev Bench for Python
|
|
2
|
+
|
|
3
|
+
Errors, who they hit, and the server log lines of the request that failed,
|
|
4
|
+
from Django, FastAPI, Flask and Celery to Dev Bench. Zero runtime
|
|
5
|
+
dependencies (the standard library only); Python 3.9+.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install devbench
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Set **one** value in the app's environment, the DSN `adt dsn create`
|
|
14
|
+
printed for this environment:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
DEVBENCH_DSN=https://<public>:<secret>@adt-ingest.onrender.com
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then add one line where your framework wants it.
|
|
21
|
+
|
|
22
|
+
### Django
|
|
23
|
+
|
|
24
|
+
In `settings.py`:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
INSTALLED_APPS = [
|
|
28
|
+
"devbench.django",
|
|
29
|
+
# ...
|
|
30
|
+
]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That is the whole install: at startup it puts its middleware first in
|
|
34
|
+
`MIDDLEWARE` (not again if you list `devbench.django.middleware.DevbenchMiddleware`
|
|
35
|
+
yourself), reports what Django answers with a 500, keeps log lines, and,
|
|
36
|
+
when Celery is loaded, installs the Celery hooks. WSGI and ASGI. Django 4.2+.
|
|
37
|
+
|
|
38
|
+
### FastAPI (and Starlette)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from fastapi import FastAPI
|
|
42
|
+
from devbench.fastapi import DevbenchMiddleware
|
|
43
|
+
|
|
44
|
+
app = FastAPI()
|
|
45
|
+
app.add_middleware(DevbenchMiddleware)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Async and sync endpoints alike. Any other ASGI app: `devbench.asgi.DevbenchMiddleware`.
|
|
49
|
+
|
|
50
|
+
### Flask
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from flask import Flask
|
|
54
|
+
from devbench.flask import Devbench
|
|
55
|
+
|
|
56
|
+
app = Flask(__name__)
|
|
57
|
+
Devbench(app) # or: Devbench().init_app(app)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Any other WSGI app: `application = devbench.wsgi.DevbenchMiddleware(application)`.
|
|
61
|
+
|
|
62
|
+
### Celery
|
|
63
|
+
|
|
64
|
+
Under Django there is nothing to add. Otherwise, where your Celery app is
|
|
65
|
+
created:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import devbench.celery
|
|
69
|
+
devbench.celery.install()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Check it
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
python -m devbench test # Django: python manage.py devbench_test
|
|
76
|
+
# Dev Bench: checking the DSN against https://adt-ingest.onrender.com (service "acme_shop")
|
|
77
|
+
# HTTP 200: accepted - tenant "acme", environment "production". Nothing was recorded; real errors will appear as issues.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It prints the HTTP result, or says plainly what is wrong (`DEVBENCH_DSN is
|
|
81
|
+
not set`, `HTTP 401: the key in DEVBENCH_DSN was rejected`, `could not reach
|
|
82
|
+
…`) and exits non-zero. It records nothing.
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
All optional except the DSN.
|
|
87
|
+
|
|
88
|
+
| Variable | Default | |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `DEVBENCH_DSN` | — (falls back to `ADT_DSN`) | `https://<public>:<secret>@<host>[:port]` from `adt dsn create`. The server authenticates with the secret. Plain `http://` is accepted only for localhost. Unset: nothing is sent (there is no sidecar mode). |
|
|
91
|
+
| `DEVBENCH_SERVICE` | Django: the project (`acme_shop` from `acme_shop.settings`); Flask: the app's import name; else `app`; **`<that>-celery` in a Celery worker** | Groups this app's issues. Set it and it wins everywhere. |
|
|
92
|
+
| `DEVBENCH_RELEASE` | the first found of: a `REVISION` file in the app root (Django's `BASE_DIR`, else the working directory), `HEROKU_SLUG_COMMIT`, `KAMAL_VERSION`, `GITHUB_SHA`, `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`; else empty | Which deploy an occurrence came from. |
|
|
93
|
+
| `DEVBENCH_ENABLED` | on | `false` turns everything off: no hooks, no headers, nothing sent. |
|
|
94
|
+
|
|
95
|
+
Or from code, before the first report:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import devbench
|
|
99
|
+
devbench.configure(dsn=secrets["devbench_dsn"], service="billing-api")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A DSN that does not parse logs one `[devbench]` warning (on the `devbench`
|
|
103
|
+
logger) and reports nothing; it never raises into the app. Neither key is
|
|
104
|
+
ever printed.
|
|
105
|
+
|
|
106
|
+
## What is captured automatically
|
|
107
|
+
|
|
108
|
+
| What | How | `context` |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| A Django view or middleware error answered with a 500 | `got_request_exception` | `request` |
|
|
111
|
+
| A Flask view error answered with a 500 | `got_request_exception` | `request` |
|
|
112
|
+
| An exception escaping a FastAPI/Starlette app (still answered 500 by Starlette, and your exception handlers still run) | the ASGI middleware reports, then re-raises the same object | `request` |
|
|
113
|
+
| A failed Celery task (the last attempt; `self.retry()` is not a failure) | `task_failure` | `job` |
|
|
114
|
+
|
|
115
|
+
Each report carries the class (`module.QualName`; builtins plainly, e.g.
|
|
116
|
+
`KeyError`), the message (≤ 2,000 characters), where it surfaced (the view,
|
|
117
|
+
endpoint or task: `shop.views.checkout`, `acme.charge`), up to 50 stack
|
|
118
|
+
frames (innermost first, paths relative to the app root), the trace, and
|
|
119
|
+
the user.
|
|
120
|
+
|
|
121
|
+
- **Once per exception.** An error that passes several hooks is reported
|
|
122
|
+
once.
|
|
123
|
+
- **Status codes are not failures.** Django's `Http404`, `PermissionDenied`,
|
|
124
|
+
`BadRequest`, `SuspiciousOperation`; werkzeug and Starlette
|
|
125
|
+
`HTTPException`s; Celery's `Retry`, `Ignore`, `Reject`; and anything that
|
|
126
|
+
is not an `Exception` (`SystemExit`, `KeyboardInterrupt`,
|
|
127
|
+
`asyncio.CancelledError`) are never reported. Add your own by name:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
devbench.ignore_exceptions().append("myapp.errors.NotAuthorized")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- **Never in the way.** No hook raises into the app; a failure while
|
|
134
|
+
reporting is dropped, and the application's own exception always comes
|
|
135
|
+
out unchanged.
|
|
136
|
+
|
|
137
|
+
## What is sent, and when
|
|
138
|
+
|
|
139
|
+
- Each report is **fingerprinted and counted in the process** (class,
|
|
140
|
+
templated message and in-app frames; the same algorithm as every other
|
|
141
|
+
Dev Bench SDK). Repeats cost a counter increment.
|
|
142
|
+
- **Once a minute** a background thread sends the counts (fingerprint, how
|
|
143
|
+
many, first/last seen, up to 20 affected users each) in one small request.
|
|
144
|
+
Nothing is sent for a quiet minute (except a small poll while the process
|
|
145
|
+
holds log lines, below), and what is left is sent when the process exits
|
|
146
|
+
(waiting at most 2 seconds; a Celery prefork child flushes at shutdown).
|
|
147
|
+
- **Detail only on request.** When Dev Bench sees a fingerprint for the
|
|
148
|
+
first time it asks for evidence, and the SDK uploads the stack and one
|
|
149
|
+
example message, **templated and scrubbed first** (emails, numbers, quoted
|
|
150
|
+
values, tokens, card numbers, credentials and two-word names are removed).
|
|
151
|
+
Who was affected never goes into evidence.
|
|
152
|
+
- **Never harms the app.** No network I/O on a request thread; 5-second
|
|
153
|
+
timeouts on the background thread, one retry, then the data is dropped;
|
|
154
|
+
memory is bounded (512 distinct problems per minute, the rest only
|
|
155
|
+
counted). Safe under forking servers (gunicorn, uWSGI, Celery prefork):
|
|
156
|
+
each process reports on its own.
|
|
157
|
+
|
|
158
|
+
## Server log lines
|
|
159
|
+
|
|
160
|
+
Triage reads the server's log lines for the user action that failed. The
|
|
161
|
+
SDK keeps them itself, no sidecar:
|
|
162
|
+
|
|
163
|
+
- **What:** every record that reaches the root logger's handlers while a
|
|
164
|
+
request or task carries a trace (`x-adt-trace` from the browser sensor; a
|
|
165
|
+
Celery task inherits the trace of the request that enqueued it). It sees
|
|
166
|
+
exactly what your logging configuration already lets through: it never
|
|
167
|
+
turns on a level you have off, writes nothing anywhere, and leaves your
|
|
168
|
+
handlers unchanged. A logger with `propagate = False` is not seen. An
|
|
169
|
+
exception's traceback is held one line each. Untraced records are not
|
|
170
|
+
kept.
|
|
171
|
+
- **Bounded:** per process, the newest 10,000 lines or 4 MiB, nothing older
|
|
172
|
+
than 15 minutes, 4 KiB per line. A logging call never waits on I/O and
|
|
173
|
+
never raises because of this.
|
|
174
|
+
- **Sent only when asked:** when Dev Bench asks for a trace, the flush
|
|
175
|
+
thread sends that trace's lines from this process (the newest ≤ 200 lines
|
|
176
|
+
/ 64 KiB), **redacted first** with the same strict rules as evidence, plus
|
|
177
|
+
any redaction rules Dev Bench has learned for your app.
|
|
178
|
+
- **Several processes:** each gunicorn worker and Celery process keeps and
|
|
179
|
+
answers for its own lines (a task's lines are in the worker, under the
|
|
180
|
+
trace of the request that enqueued it); Dev Bench assembles them.
|
|
181
|
+
|
|
182
|
+
A plain WSGI/ASGI app without one of the integrations: `devbench.capture_logs()`.
|
|
183
|
+
|
|
184
|
+
## Who was affected
|
|
185
|
+
|
|
186
|
+
Set it once per request, after your authentication has run (a middleware
|
|
187
|
+
after Django's `AuthenticationMiddleware`, a FastAPI dependency, a Flask
|
|
188
|
+
`before_request`):
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
devbench.set_user(email=request.user.email, account=request.user.account_id)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- `email` is trimmed and lowercased; `account` is your own tenant/customer
|
|
195
|
+
id, sent as a string. Both optional, each truncated to 255 characters.
|
|
196
|
+
- It is sent in its own field with the counts, never inside a message or
|
|
197
|
+
evidence.
|
|
198
|
+
- It ends with the request (a Celery task: with the task), so the next
|
|
199
|
+
request on the same thread never inherits it.
|
|
200
|
+
|
|
201
|
+
## Reporting what you caught
|
|
202
|
+
|
|
203
|
+
An exception you caught but want seen, like Sentry's `capture_exception`:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
except requests.RequestException as e:
|
|
207
|
+
devbench.capture_exception(e, symbol="billing.sync")
|
|
208
|
+
retry_later()
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A failure you handled and turned into a normal response, the case this
|
|
212
|
+
product exists for:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
except IntegrityError as e:
|
|
216
|
+
devbench.report_handled(e, symbol="customers.views.update", reason="uniqueness")
|
|
217
|
+
return JsonResponse({"ok": True}) # <- the user is about to be told this worked
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`report_handled` also marks the response with `x-adt-handled: <count>` (and
|
|
221
|
+
lists it in `Access-Control-Expose-Headers`, appending to what is there).
|
|
222
|
+
The browser already knows whether the user was shown an error; this is the
|
|
223
|
+
one fact it cannot know on its own. The header carries a count and nothing
|
|
224
|
+
else, and it is added even when reporting is down.
|
|
225
|
+
|
|
226
|
+
## Trace propagation
|
|
227
|
+
|
|
228
|
+
The correlation id the browser sent (`x-adt-trace`) is `devbench.current_trace()`
|
|
229
|
+
for the duration of the request. Forward it on outbound calls to other
|
|
230
|
+
instrumented services:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
requests.post(url, json=body, headers=devbench.trace_headers())
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Not in this release
|
|
237
|
+
|
|
238
|
+
- No sidecar transport: without a DSN nothing is sent.
|
|
239
|
+
- `devbench.track` (custom events) and the chat widget's signed identity
|
|
240
|
+
are in the Ruby and Go SDKs; they come to Python later.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Dev Bench server SDK for Python.
|
|
2
|
+
|
|
3
|
+
pip install devbench
|
|
4
|
+
DEVBENCH_DSN=https://<public>:<secret>@<ingest host> (from `adt dsn create`)
|
|
5
|
+
|
|
6
|
+
then one line where your framework wants it (README): Django
|
|
7
|
+
(``"devbench.django"`` in INSTALLED_APPS), FastAPI/Starlette
|
|
8
|
+
(``app.add_middleware(DevbenchMiddleware)``), Flask (``Devbench(app)``),
|
|
9
|
+
Celery (``devbench.celery.install()``; automatic under Django).
|
|
10
|
+
|
|
11
|
+
What it does:
|
|
12
|
+
|
|
13
|
+
* accepts and forwards the correlation id (the middlewares, trace_headers)
|
|
14
|
+
* reports unhandled exceptions (the middlewares, Celery hooks,
|
|
15
|
+
capture_exception)
|
|
16
|
+
* reports handled failures (report_handled)
|
|
17
|
+
* records who was affected (set_user)
|
|
18
|
+
* keeps recent log lines per trace (capture_logs; a logging handler)
|
|
19
|
+
|
|
20
|
+
Nothing here raises into the application.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Dict, Optional
|
|
26
|
+
|
|
27
|
+
from . import _core, context, reporter
|
|
28
|
+
from .config import DSN, Configuration, InvalidDSN
|
|
29
|
+
from .identity import set_user
|
|
30
|
+
from .trace import HEADER as TRACE_HEADER
|
|
31
|
+
from .trace import Trace
|
|
32
|
+
from .version import PROTOCOL_VERSION, VERSION
|
|
33
|
+
|
|
34
|
+
__version__ = VERSION
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"PROTOCOL_VERSION", "VERSION", "DSN", "Configuration", "InvalidDSN", "Trace", "TRACE_HEADER",
|
|
38
|
+
"configure", "enabled", "set_user", "capture_exception", "report_handled", "ignore_exceptions",
|
|
39
|
+
"trace_headers", "current_trace", "capture_logs", "flush", "test",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure(dsn: Optional[str] = None, service: Optional[str] = None, release: Optional[str] = None,
|
|
44
|
+
enabled: Optional[bool] = None, flush_interval: Optional[float] = None) -> None:
|
|
45
|
+
"""Sets any of the environment's values from code (only those given)::
|
|
46
|
+
|
|
47
|
+
devbench.configure(dsn=secrets["devbench_dsn"], service="billing-api")
|
|
48
|
+
|
|
49
|
+
Takes effect from the next report. Never raises."""
|
|
50
|
+
_core.configure(dsn=dsn, service=service, release=release, enabled=enabled, flush_interval=flush_interval)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def enabled() -> bool:
|
|
54
|
+
"""False when DEVBENCH_ENABLED=false (or configure(enabled=False))."""
|
|
55
|
+
return _core.enabled()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def capture_exception(error: Optional[BaseException] = None, symbol: Optional[str] = None) -> None:
|
|
59
|
+
"""Reports an exception the application caught and wants seen, as
|
|
60
|
+
Sentry's capture_exception does (the one being handled when ``error`` is
|
|
61
|
+
omitted). Returns None and never raises::
|
|
62
|
+
|
|
63
|
+
except requests.RequestException as e:
|
|
64
|
+
devbench.capture_exception(e, symbol="billing.sync")
|
|
65
|
+
"""
|
|
66
|
+
if error is None:
|
|
67
|
+
import sys
|
|
68
|
+
|
|
69
|
+
error = sys.exc_info()[1]
|
|
70
|
+
reporter.capture(error, "explicit", True, symbol)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def report_handled(error: Any, symbol: str, reason: Optional[str] = None) -> None:
|
|
74
|
+
"""Records that a failure was handled (caught and not re-raised) here.
|
|
75
|
+
Does not change behaviour. Repeats at the same site are counted::
|
|
76
|
+
|
|
77
|
+
except IntegrityError as e:
|
|
78
|
+
devbench.report_handled(e, symbol="customers.views.update", reason="uniqueness")
|
|
79
|
+
"""
|
|
80
|
+
reporter.report_handled(error, symbol, reason)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ignore_exceptions() -> list:
|
|
84
|
+
"""Class names (``module.QualName``) never reported; append your own."""
|
|
85
|
+
return reporter.ignore_exceptions()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def current_trace() -> Optional[Trace]:
|
|
89
|
+
"""The x-adt-trace of the request or job being handled, or None."""
|
|
90
|
+
return context.current_trace()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def trace_headers() -> Dict[str, str]:
|
|
94
|
+
"""Headers to add to an outbound HTTP call to another instrumented
|
|
95
|
+
service: the active trace with its hop incremented, or {}::
|
|
96
|
+
|
|
97
|
+
requests.post(url, json=body, headers={**headers, **devbench.trace_headers()})
|
|
98
|
+
"""
|
|
99
|
+
trace = context.current_trace()
|
|
100
|
+
if trace is None:
|
|
101
|
+
return {}
|
|
102
|
+
forwarded = trace.next_hop()
|
|
103
|
+
return {TRACE_HEADER: str(forwarded)} if forwarded is not None else {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def capture_logs() -> bool:
|
|
107
|
+
"""Keeps recent log lines per trace for triage (direct mode). The
|
|
108
|
+
integrations call this; a plain WSGI/ASGI app may call it itself."""
|
|
109
|
+
return _core.capture_logs()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
113
|
+
"""Sends whatever has been counted now, waiting at most ``timeout``
|
|
114
|
+
seconds. Returns True when nothing is left unsent."""
|
|
115
|
+
return _core.flush(timeout)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test(out: Any = None) -> bool:
|
|
119
|
+
"""Checks DEVBENCH_DSN against ingest and prints the result; the same as
|
|
120
|
+
``python -m devbench test``."""
|
|
121
|
+
return _core.self_test(out)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""``python -m devbench test``: checks DEVBENCH_DSN against Dev Bench and
|
|
2
|
+
prints the HTTP result, without waiting for a flush. Exits non-zero when the
|
|
3
|
+
DSN is missing, malformed, unreachable or rejected."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from . import _core
|
|
11
|
+
|
|
12
|
+
USAGE = "usage: python -m devbench test\n"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
16
|
+
args = sys.argv[1:] if argv is None else argv
|
|
17
|
+
if args != ["test"]:
|
|
18
|
+
sys.stderr.write(USAGE)
|
|
19
|
+
return 2
|
|
20
|
+
return 0 if _core.self_test(sys.stdout) else 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
sys.exit(main())
|