devbench 0.1.0__py3-none-any.whl

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/scrub.py ADDED
@@ -0,0 +1,247 @@
1
+ """A port of the sidecar's strict egress (internal/scrub and
2
+ Sidecar.forEgress): what this SDK does to an exemplar or a log line before it
3
+ leaves the process, so leaving out the sidecar does not loosen redaction
4
+ (DECISIONS #160). Held to testdata/redaction_vectors.json.
5
+
6
+ Strict mode is the only mode: template (values the format marks: quoted
7
+ strings, numbers, durations), then scrub (values with a shape: emails, cards,
8
+ tokens, credentials, IPs), then the prose heuristic (runs of capitalised words
9
+ that are not framework vocabulary).
10
+
11
+ Patterns are Go's, with Go's ``\\b``, ``\\d`` and ``\\s`` (see fingerprint).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import TYPE_CHECKING, Optional, Tuple
18
+
19
+ from . import fingerprint as fp_mod
20
+ from .fingerprint import GO_NOT_WS as NOT_WS
21
+ from .fingerprint import GO_WS as WS
22
+ from .fingerprint import go_re, utf8
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ from .egress_policy import EgressPolicy
26
+
27
+ # The card and phone rules (#186; internal/scrub/numbers.go, the `numbers`
28
+ # vectors): a candidate regex plus checks on the whole candidate, run in the
29
+ # replacement callback. A rejected candidate is left as written and matching
30
+ # carries on after it; it is never retried shorter. No lookaround in the
31
+ # candidates (the Go SDK is RE2), so every SDK finds the same ones.
32
+ CARD = "<redacted:card>"
33
+ PHONE = "<redacted:phone>"
34
+ CARD_CANDIDATE = go_re(r"\b(?:\d[ \-]*?){13,19}\b")
35
+ # Group 1 is the character before the number (or the start of the text): a
36
+ # phone number is not glued to a letter, digit, `_` or `-`, which keeps a
37
+ # UUID's tail and `ord_123456789` out. Group 2 is the candidate. Spelled out
38
+ # rather than through go_re, whose \d rewrite does not apply inside a class.
39
+ PHONE_CANDIDATE = re.compile(r"(^|[^A-Za-z0-9_\-])(\+?[0-9][0-9\t\n\f\r ().\-]{7,}[0-9])")
40
+ ISO_DATE_PREFIX = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}(?:\Z|[^0-9])")
41
+ DECIMAL = re.compile(r"[0-9]+\.[0-9]+")
42
+ WORD_CHAR = re.compile(r"[A-Za-z0-9_]")
43
+ NOT_DIGIT = re.compile(r"[^0-9]")
44
+ # E.164 caps a phone number at 15 digits; anything longer is an id.
45
+ MIN_PHONE_DIGITS = 7
46
+ MAX_PHONE_DIGITS = 15
47
+
48
+
49
+ def _is_timestamp(digits: str) -> bool:
50
+ """A bare Unix timestamp in seconds or milliseconds (2001 to 2033)."""
51
+ return len(digits) in (10, 13) and digits[0] == "1"
52
+
53
+
54
+ def _luhn(digits: str) -> bool:
55
+ if not digits:
56
+ return False
57
+ total = 0
58
+ for i, c in enumerate(reversed(digits)):
59
+ d = ord(c) - 48
60
+ if i % 2 == 1:
61
+ d *= 2
62
+ if d > 9:
63
+ d -= 9
64
+ total += d
65
+ return total % 10 == 0
66
+
67
+
68
+ def _is_card(candidate: str) -> bool:
69
+ """The digits pass the Luhn check, and the candidate is not a bare
70
+ 13-digit run starting with 1 (a millisecond timestamp)."""
71
+ digits = NOT_DIGIT.sub("", candidate)
72
+ if digits == candidate and _is_timestamp(digits):
73
+ return False
74
+ return _luhn(digits)
75
+
76
+
77
+ def _is_phone(candidate: str, after: str) -> bool:
78
+ """Judged with the character after it ('' at the end of the text). Not a
79
+ phone when a letter, digit or `_` follows; it starts with an ISO date; it
80
+ is a decimal; it has fewer than 7 or more than 15 digits; or it is a bare
81
+ 10- or 13-digit run starting with 1 (a timestamp)."""
82
+ if WORD_CHAR.match(after):
83
+ return False
84
+ if ISO_DATE_PREFIX.match(candidate) or DECIMAL.fullmatch(candidate):
85
+ return False
86
+ digits = NOT_DIGIT.sub("", candidate)
87
+ if not MIN_PHONE_DIGITS <= len(digits) <= MAX_PHONE_DIGITS:
88
+ return False
89
+ return not (digits == candidate and _is_timestamp(digits))
90
+
91
+
92
+ # Ordered most-specific first, and the order is load-bearing: see the
93
+ # comments on internal/scrub's rules (a credential containing an email, a
94
+ # phone pattern eating an SSN).
95
+ RULES = [
96
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"), "<redacted:private-key>"),
97
+ (go_re(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}\b"), "<redacted:jwt>"),
98
+ (go_re(r"([a-zA-Z][a-zA-Z0-9+.\-]*://)[^\t\n\f\r :/@]+:[^\t\n\f\r /@]+@"), r"\g<1><redacted:credentials>@"),
99
+ (go_re(r"\b(authorization|proxy-authorization)(" + WS + "*[:=]" + WS + "*)(?:" + NOT_WS + r"+[ \t]+)?" + NOT_WS + "+",
100
+ re.IGNORECASE), r"\g<1>\g<2><redacted:authorization>"),
101
+ (go_re(r"\b(bearer|basic)" + WS + r"+[A-Za-z0-9._\-=/+]{8,}", re.IGNORECASE), r"\g<1> <redacted:token>"),
102
+ (go_re(r"\bAKIA[0-9A-Z]{16}\b"), "<redacted:aws-key>"),
103
+ (go_re(r"\b(password|passwd|pwd|secret|api[_\-]?key|access[_\-]?token|refresh[_\-]?token|"
104
+ r"client[_\-]?secret|private[_\-]?key|session[_\-]?id|csrf[_\-]?token)"
105
+ "(" + WS + "*[:=]" + WS + "*)(\"[^\"]*\"|'[^']*'|" + NOT_WS + "+)", re.IGNORECASE),
106
+ r"\g<1>\g<2><redacted:secret>"),
107
+ (go_re(r"\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b"), "<redacted:email>"),
108
+ (go_re(r"\b\d{3}-\d{2}-\d{4}\b"), "<redacted:ssn>"),
109
+ # The card and phone rules (#186) judge each candidate in the callback.
110
+ (CARD_CANDIDATE, lambda m: CARD if _is_card(m.group(0)) else m.group(0)),
111
+ (go_re(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"), "<redacted:ip>"),
112
+ (PHONE_CANDIDATE,
113
+ lambda m: m.group(1) + PHONE if _is_phone(m.group(2), m.string[m.end():m.end() + 1]) else m.group(0)),
114
+ ]
115
+
116
+ # internal/sidecar/trace.go's traceRe.
117
+ TRACE = go_re(r"\bv1/([A-Za-z0-9_-]{1,64})/([A-Za-z0-9_-]{1,64})/(\d{1,3})\b")
118
+
119
+ # What Rails, Go and HTTP say, lowercased (internal/scrub/prose.go). Masking
120
+ # any of these would destroy diagnostic content.
121
+ VOCABULARY = frozenset("""
122
+ ok created accepted content moved permanently found modified bad request
123
+ unauthorized payment required forbidden not method allowed acceptable
124
+ timeout conflict gone unsupported media type unprocessable entity too many
125
+ requests internal server error implemented gateway service unavailable no
126
+ see other temporary redirect
127
+ started completed processing rendered rendering redirected filter chain
128
+ halted parameters views load exists destroy update create transaction
129
+ rollback commit cache performed performing enqueued retrying rescued
130
+ validation failed invalid email has already been taken
131
+ panic goroutine runtime fatal context deadline exceeded canceled cancelled
132
+ connection refused such host timeout_ closed reset peer broken pipe
133
+ temporarily
134
+ warning warn info debug trace exception failure retry skipping unknown
135
+ missing expired denied true false nil null none
136
+ """.split())
137
+
138
+ FIELD = re.compile("[^" + fp_mod.GO_SPACE_CLASS + "]+")
139
+ TRAILING_PUNCT = re.compile(r"[.,;:!?)\"']+\Z")
140
+ CODE_CHARS = re.compile(r"[:#/()_=<>@\[\]{}\"']")
141
+ ASCII_DIGIT = re.compile("[0-9]")
142
+
143
+ # Shape rules (EgressPolicy).
144
+ MASKED = "masked"
145
+ NONE = "none"
146
+ FULL = "full"
147
+
148
+
149
+ def text(line: str) -> str:
150
+ """internal/scrub.Text: every shape rule, in order."""
151
+ out = utf8(line)
152
+ for pattern, replacement in RULES:
153
+ out = pattern.sub(replacement, out)
154
+ return out
155
+
156
+
157
+ def prose(line: str) -> str:
158
+ """internal/scrub.Prose: runs of two or more capitalised words that are
159
+ not framework vocabulary become <redacted:name>."""
160
+ if not line:
161
+ return line
162
+ fields = FIELD.findall(line)
163
+ if len(fields) < 2:
164
+ return line
165
+ out = []
166
+ run: list = []
167
+
168
+ def flush() -> None:
169
+ if len(run) >= 2:
170
+ out.append("<redacted:name>")
171
+ elif len(run) == 1:
172
+ out.append(run[0])
173
+ del run[:]
174
+
175
+ for field in fields:
176
+ if _proper_noun(field):
177
+ run.append(field)
178
+ else:
179
+ flush()
180
+ out.append(field)
181
+ flush()
182
+ return " ".join(out)
183
+
184
+
185
+ def egress(line: str, policy: "Optional[EgressPolicy]" = None, service: str = "") -> str:
186
+ """The sidecar's forEgress in strict mode, for one line: any trace kept as
187
+ is, the rest templated, scrubbed and prose-masked, then the learned rules
188
+ (an EgressPolicy), exactly as Sidecar.redactBody applies them:
189
+
190
+ * shape 'none': the content never leaves: <withheld:shape:<fp12>>
191
+ * shape 'full': lifts only the prose heuristic, never base redaction
192
+ * shape 'masked': prose heuristic (already on in strict mode)
193
+ * field / term: masked afterwards, whatever the shape rule says
194
+ """
195
+ line = utf8(line)
196
+ match = TRACE.search(line)
197
+ body = TRACE.sub("<trace>", line) if match else line
198
+ fp, rule = _shape_rule(policy, service, body)
199
+ redacted = withheld(fp) if rule == NONE else _redact_body(body, rule, policy)
200
+ return "[%s] %s" % (match.group(0), redacted) if match else redacted
201
+
202
+
203
+ def template_text(value: str, policy: "Optional[EgressPolicy]" = None, fp: Optional[str] = None) -> str:
204
+ """A bundle's template text: as the sidecar's answerEvidenceRequests does,
205
+ the shape's own rule (by the signal's fingerprint), strict redaction, then
206
+ a second shape pass. Empty when the shape's content never leaves."""
207
+ rule = policy.rule_for(fp) if policy is not None and fp else None
208
+ if rule == NONE:
209
+ return ""
210
+ return text(_redact_body(utf8(value), rule, policy))
211
+
212
+
213
+ def withheld(fp: Optional[str]) -> str:
214
+ """What stands in for a line whose shape is marked 'none'."""
215
+ return "<withheld:shape:%s>" % (fp or "")[:12]
216
+
217
+
218
+ def _redact_body(body: str, rule: Optional[str], policy: "Optional[EgressPolicy]") -> str:
219
+ redacted = text(fp_mod.template(body))
220
+ if rule != FULL:
221
+ redacted = prose(redacted)
222
+ return policy.mask(redacted) if policy is not None else redacted
223
+
224
+
225
+ def _shape_rule(policy: "Optional[EgressPolicy]", service: str, body: str) -> Tuple[Optional[str], Optional[str]]:
226
+ if policy is None or not policy.has_shapes():
227
+ return None, None
228
+ fp = fp_mod.compute(kind="log_template", source="sidecar", service=service or "", message=body)
229
+ if fp is None:
230
+ return None, None
231
+ return fp, policy.rule_for(fp)
232
+
233
+
234
+ def _proper_noun(token: str) -> bool:
235
+ trimmed = TRAILING_PUNCT.sub("", token)
236
+ if len(trimmed.encode("utf-8", "replace")) < 2:
237
+ return False
238
+ if CODE_CHARS.search(trimmed):
239
+ return False
240
+ if trimmed == trimmed.upper():
241
+ return False
242
+ first = trimmed[0]
243
+ if not ("A" <= first <= "Z"):
244
+ return False
245
+ if ASCII_DIGIT.search(trimmed):
246
+ return False
247
+ return trimmed.lower() not in VOCABULARY
devbench/trace.py ADDED
@@ -0,0 +1,82 @@
1
+ """The correlation id that ties a user action together across every service.
2
+
3
+ Wire form (docs/SERVER_SDK_SPEC.md, Capability 1)::
4
+
5
+ x-adt-trace: v1/<session>/<intent>/<hop>
6
+
7
+ This must agree exactly with the other SDKs and the sidecar
8
+ (testdata/trace_vectors.json). A trace the browser writes and Python mangles
9
+ is worse than no trace: the request still succeeds, so nothing looks wrong,
10
+ and the evidence simply cannot be joined.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any, Optional
17
+
18
+ from .fingerprint import GO_SPACE
19
+
20
+ HEADER = "x-adt-trace"
21
+ WSGI_HEADER = "HTTP_X_ADT_TRACE"
22
+ MAX_HOP = 99
23
+ _ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
24
+ _HOP = re.compile(r"[0-9]{1,3}\Z")
25
+
26
+
27
+ class Trace:
28
+ __slots__ = ("session", "intent", "hop")
29
+
30
+ def __init__(self, session: str, intent: str, hop: int) -> None:
31
+ self.session = session
32
+ self.intent = intent
33
+ self.hop = hop
34
+
35
+ @classmethod
36
+ def parse(cls, value: Any) -> "Optional[Trace]":
37
+ """Parses a header value. Returns None rather than raising: Dev Bench
38
+ must never fail a customer's request, and a request carrying a mangled
39
+ correlation id is still a request the user wants served."""
40
+ if isinstance(value, bytes):
41
+ try:
42
+ value = value.decode("ascii")
43
+ except UnicodeDecodeError:
44
+ return None
45
+ if not isinstance(value, str):
46
+ return None
47
+ value = value.strip(GO_SPACE) # Go: strings.TrimSpace
48
+ if not value.startswith("v1/"):
49
+ return None
50
+ parts = value[3:].split("/")
51
+ if len(parts) != 3:
52
+ return None
53
+ session, intent, hop = parts
54
+ if not _ID.match(session) or not _ID.match(intent) or not _HOP.match(hop):
55
+ return None
56
+ number = int(hop)
57
+ if number > MAX_HOP:
58
+ return None
59
+ return cls(session, intent, number)
60
+
61
+ def __str__(self) -> str:
62
+ return "v1/%s/%s/%d" % (self.session, self.intent, self.hop)
63
+
64
+ def __repr__(self) -> str:
65
+ return "Trace(%r)" % str(self)
66
+
67
+ @property
68
+ def key(self) -> str:
69
+ """Identifies the user action, independent of which service saw it."""
70
+ return "%s/%s" % (self.session, self.intent)
71
+
72
+ def next_hop(self) -> "Optional[Trace]":
73
+ """The trace to send to a downstream service, or None at the limit."""
74
+ if self.hop >= MAX_HOP:
75
+ return None
76
+ return Trace(self.session, self.intent, self.hop + 1)
77
+
78
+ def __eq__(self, other: object) -> bool:
79
+ return isinstance(other, Trace) and (self.session, self.intent, self.hop) == (other.session, other.intent, other.hop)
80
+
81
+ def __hash__(self) -> int:
82
+ return hash((self.session, self.intent, self.hop))
devbench/version.py ADDED
@@ -0,0 +1,12 @@
1
+ """The SDK's release version and the protocol version it speaks."""
2
+
3
+ # This package's own release (python-v<VERSION>). The SDKs release
4
+ # independently (docs/specs/sdk-python-php-dotnet.md, "Versioning").
5
+ VERSION = "0.1.0"
6
+
7
+ # The wire format this speaks: the `v` every payload carries (the flush body
8
+ # and the evidence bundle). Not the release VERSION above: it changes only for
9
+ # a breaking wire change, and ingest tells formats apart by it. It must equal
10
+ # `current` in testdata/protocol_vectors.json (docs/SERVER_SDK_SPEC.md,
11
+ # "Protocol version"); send it, never a literal.
12
+ PROTOCOL_VERSION = 1
devbench/wsgi.py ADDED
@@ -0,0 +1,67 @@
1
+ """WSGI middleware: accepts the correlation id for the duration of the
2
+ request, scopes identity to it, reports an exception that escapes the app
3
+ (then re-raises the same object), and marks a traced response during which
4
+ failures were handled (``x-adt-handled``).
5
+
6
+ Flask's integration (``devbench.flask.Devbench``) installs it; any other WSGI
7
+ app can wrap itself::
8
+
9
+ application = DevbenchMiddleware(application)
10
+
11
+ Never rejects a request, never alters the response body, never raises
12
+ anything of its own.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Callable, Iterable, Optional
18
+
19
+ from . import _core, _integration, context
20
+ from .trace import WSGI_HEADER, Trace
21
+
22
+
23
+ class DevbenchMiddleware:
24
+ def __init__(self, app: Callable[..., Iterable[bytes]], symbol: Optional[Callable[[dict], Optional[str]]] = None) -> None:
25
+ self.app = app
26
+ # Where a failure surfaced, read from the environ after the app ran.
27
+ self._symbol = symbol
28
+ _integration.setup()
29
+
30
+ def __call__(self, environ: dict, start_response: Callable[..., Any]) -> Iterable[bytes]:
31
+ # DEVBENCH_ENABLED=false: not even the scope or the header.
32
+ if not _core.enabled():
33
+ return self.app(environ, start_response)
34
+ trace = _parse(environ)
35
+
36
+ def start(status: str, headers: list, exc_info: Any = None) -> Any:
37
+ try:
38
+ headers = _integration.with_handled(list(headers))
39
+ except Exception:
40
+ pass
41
+ if exc_info is not None:
42
+ return start_response(status, headers, exc_info)
43
+ return start_response(status, headers)
44
+
45
+ # Scoped even without a trace: identity set during this request must
46
+ # end with it, and a thread's next request must not inherit it.
47
+ with context.scoped(trace):
48
+ try:
49
+ return self.app(environ, start)
50
+ except Exception as error:
51
+ # Reported inside the scope, so the report carries this
52
+ # request's trace and user. capture never raises.
53
+ _integration.report_request_error(error, self._symbol_for(environ))
54
+ raise
55
+
56
+ def _symbol_for(self, environ: dict) -> Optional[str]:
57
+ try:
58
+ return self._symbol(environ) if self._symbol is not None else None
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def _parse(environ: dict) -> Optional[Trace]:
64
+ try:
65
+ return Trace.parse(environ.get(WSGI_HEADER))
66
+ except Exception:
67
+ return None
@@ -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.