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/__init__.py +121 -0
- devbench/__main__.py +24 -0
- devbench/_core.py +252 -0
- devbench/_integration.py +107 -0
- devbench/asgi.py +82 -0
- devbench/celery.py +152 -0
- devbench/config.py +189 -0
- devbench/context.py +94 -0
- devbench/direct.py +639 -0
- devbench/django/__init__.py +12 -0
- devbench/django/apps.py +68 -0
- devbench/django/management/__init__.py +0 -0
- devbench/django/management/commands/__init__.py +0 -0
- devbench/django/management/commands/devbench_test.py +18 -0
- devbench/django/middleware.py +87 -0
- devbench/egress_policy.py +101 -0
- devbench/fastapi.py +14 -0
- devbench/fingerprint.py +199 -0
- devbench/flask.py +54 -0
- devbench/identity.py +50 -0
- devbench/logs.py +282 -0
- devbench/py.typed +0 -0
- devbench/reporter.py +233 -0
- devbench/scrub.py +247 -0
- devbench/trace.py +82 -0
- devbench/version.py +12 -0
- devbench/wsgi.py +67 -0
- devbench-0.1.0.dist-info/METADATA +259 -0
- devbench-0.1.0.dist-info/RECORD +32 -0
- devbench-0.1.0.dist-info/WHEEL +5 -0
- devbench-0.1.0.dist-info/licenses/LICENSE +21 -0
- devbench-0.1.0.dist-info/top_level.txt +1 -0
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.
|