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.
Files changed (45) hide show
  1. devbench-0.1.0/LICENSE +21 -0
  2. devbench-0.1.0/PKG-INFO +259 -0
  3. devbench-0.1.0/README.md +240 -0
  4. devbench-0.1.0/devbench/__init__.py +121 -0
  5. devbench-0.1.0/devbench/__main__.py +24 -0
  6. devbench-0.1.0/devbench/_core.py +252 -0
  7. devbench-0.1.0/devbench/_integration.py +107 -0
  8. devbench-0.1.0/devbench/asgi.py +82 -0
  9. devbench-0.1.0/devbench/celery.py +152 -0
  10. devbench-0.1.0/devbench/config.py +189 -0
  11. devbench-0.1.0/devbench/context.py +94 -0
  12. devbench-0.1.0/devbench/direct.py +639 -0
  13. devbench-0.1.0/devbench/django/__init__.py +12 -0
  14. devbench-0.1.0/devbench/django/apps.py +68 -0
  15. devbench-0.1.0/devbench/django/management/__init__.py +0 -0
  16. devbench-0.1.0/devbench/django/management/commands/__init__.py +0 -0
  17. devbench-0.1.0/devbench/django/management/commands/devbench_test.py +18 -0
  18. devbench-0.1.0/devbench/django/middleware.py +87 -0
  19. devbench-0.1.0/devbench/egress_policy.py +101 -0
  20. devbench-0.1.0/devbench/fastapi.py +14 -0
  21. devbench-0.1.0/devbench/fingerprint.py +199 -0
  22. devbench-0.1.0/devbench/flask.py +54 -0
  23. devbench-0.1.0/devbench/identity.py +50 -0
  24. devbench-0.1.0/devbench/logs.py +282 -0
  25. devbench-0.1.0/devbench/py.typed +0 -0
  26. devbench-0.1.0/devbench/reporter.py +233 -0
  27. devbench-0.1.0/devbench/scrub.py +247 -0
  28. devbench-0.1.0/devbench/trace.py +82 -0
  29. devbench-0.1.0/devbench/version.py +12 -0
  30. devbench-0.1.0/devbench/wsgi.py +67 -0
  31. devbench-0.1.0/devbench.egg-info/PKG-INFO +259 -0
  32. devbench-0.1.0/devbench.egg-info/SOURCES.txt +43 -0
  33. devbench-0.1.0/devbench.egg-info/dependency_links.txt +1 -0
  34. devbench-0.1.0/devbench.egg-info/top_level.txt +1 -0
  35. devbench-0.1.0/pyproject.toml +46 -0
  36. devbench-0.1.0/setup.cfg +4 -0
  37. devbench-0.1.0/tests/test_celery.py +117 -0
  38. devbench-0.1.0/tests/test_config.py +245 -0
  39. devbench-0.1.0/tests/test_direct.py +305 -0
  40. devbench-0.1.0/tests/test_django.py +136 -0
  41. devbench-0.1.0/tests/test_fastapi.py +119 -0
  42. devbench-0.1.0/tests/test_flask.py +117 -0
  43. devbench-0.1.0/tests/test_logs.py +360 -0
  44. devbench-0.1.0/tests/test_reporter.py +166 -0
  45. 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.
@@ -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.
@@ -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())