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/logs.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""In-process log capture (docs/SERVER_SDK_SPEC.md, "In-process log capture
|
|
2
|
+
(direct mode)"): recent log lines kept in memory, keyed by the trace that was
|
|
3
|
+
current when each was written, so triage can be handed the server lines of
|
|
4
|
+
one user action without a sidecar.
|
|
5
|
+
|
|
6
|
+
One way in: ``LogHandler``, a ``logging.Handler`` on the root logger
|
|
7
|
+
(``devbench.capture_logs()``; every integration installs it). It sees exactly
|
|
8
|
+
what the app's loggers already let through, so it never turns on a level the
|
|
9
|
+
app has off; it writes nothing anywhere, it only remembers.
|
|
10
|
+
|
|
11
|
+
Never harms the host: a record with no current trace costs one ContextVar
|
|
12
|
+
read; the buffer lock is held for a deque append, never across I/O or
|
|
13
|
+
redaction; nothing raises out of a logging call. Fork-safe: a child starts
|
|
14
|
+
with an empty buffer.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import collections
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Callable, Deque, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from . import context
|
|
27
|
+
|
|
28
|
+
MAX_LINES = 10_000
|
|
29
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
30
|
+
MAX_AGE = 15 * 60
|
|
31
|
+
MAX_LINE_BYTES = 4096
|
|
32
|
+
# Lines held from one multi-line message (an exception and its traceback):
|
|
33
|
+
# the top of it, so one framework traceback cannot push the rest of the
|
|
34
|
+
# request out of a 200-line slice.
|
|
35
|
+
MAX_MESSAGE_LINES = 50
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Entry:
|
|
39
|
+
__slots__ = ("key", "at", "text", "size")
|
|
40
|
+
|
|
41
|
+
def __init__(self, key: str, at: float, text: str) -> None:
|
|
42
|
+
self.key = key
|
|
43
|
+
self.at = at
|
|
44
|
+
self.text = text
|
|
45
|
+
self.size = len(text.encode("utf-8", "replace"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Buffer:
|
|
49
|
+
"""Bounded, thread-safe, per-trace store of recent lines. Oldest evicted
|
|
50
|
+
first, by count, bytes and age."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, max_lines: int = MAX_LINES, max_bytes: int = MAX_BYTES, max_age: float = MAX_AGE,
|
|
53
|
+
clock: Callable[[], float] = time.monotonic) -> None:
|
|
54
|
+
self.max_lines = max_lines
|
|
55
|
+
self.max_bytes = max_bytes
|
|
56
|
+
self.max_age = max_age
|
|
57
|
+
self._clock = clock
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._reset()
|
|
60
|
+
|
|
61
|
+
def _reset(self) -> None:
|
|
62
|
+
self._pid = os.getpid()
|
|
63
|
+
self._entries: Deque[_Entry] = collections.deque()
|
|
64
|
+
self._by_key: Dict[str, Deque[_Entry]] = {}
|
|
65
|
+
self._bytes = 0
|
|
66
|
+
|
|
67
|
+
def push(self, key: str, text: str) -> None:
|
|
68
|
+
"""Keeps one line under a trace key."""
|
|
69
|
+
now = self._clock()
|
|
70
|
+
entry = _Entry(key, now, text)
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._pid != os.getpid():
|
|
73
|
+
self._reset()
|
|
74
|
+
self._entries.append(entry)
|
|
75
|
+
self._by_key.setdefault(key, collections.deque()).append(entry)
|
|
76
|
+
self._bytes += entry.size
|
|
77
|
+
self._evict(now)
|
|
78
|
+
|
|
79
|
+
def lookup(self, key: str, limit: int) -> List[str]:
|
|
80
|
+
"""The lines held for a key, oldest first, at most ``limit`` (the most
|
|
81
|
+
recent ones when there are more). A copy: the caller redacts it
|
|
82
|
+
outside the lock."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
if self._pid != os.getpid():
|
|
85
|
+
self._reset()
|
|
86
|
+
self._evict(self._clock())
|
|
87
|
+
held = self._by_key.get(key)
|
|
88
|
+
if not held:
|
|
89
|
+
return []
|
|
90
|
+
items = list(held)
|
|
91
|
+
return [e.text for e in items[-limit:]]
|
|
92
|
+
|
|
93
|
+
def any(self) -> bool:
|
|
94
|
+
"""Whether any line younger than the age bound is held (by this
|
|
95
|
+
process)."""
|
|
96
|
+
with self._lock:
|
|
97
|
+
if self._pid != os.getpid():
|
|
98
|
+
self._reset()
|
|
99
|
+
self._evict(self._clock())
|
|
100
|
+
return bool(self._entries)
|
|
101
|
+
|
|
102
|
+
def __len__(self) -> int:
|
|
103
|
+
with self._lock:
|
|
104
|
+
return len(self._entries)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def bytes(self) -> int:
|
|
108
|
+
with self._lock:
|
|
109
|
+
return self._bytes
|
|
110
|
+
|
|
111
|
+
def clear(self) -> None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._reset()
|
|
114
|
+
|
|
115
|
+
# Every entry is the oldest of its own key when it is the oldest overall,
|
|
116
|
+
# so removing it from its key's deque is a popleft.
|
|
117
|
+
def _evict(self, now: float) -> None:
|
|
118
|
+
cutoff = now - self.max_age
|
|
119
|
+
entries = self._entries
|
|
120
|
+
while entries and (len(entries) > self.max_lines or self._bytes > self.max_bytes or entries[0].at < cutoff):
|
|
121
|
+
oldest = entries.popleft()
|
|
122
|
+
held = self._by_key.get(oldest.key)
|
|
123
|
+
if held:
|
|
124
|
+
held.popleft()
|
|
125
|
+
if not held:
|
|
126
|
+
del self._by_key[oldest.key]
|
|
127
|
+
self._bytes -= oldest.size
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_buffer = Buffer()
|
|
131
|
+
_active = False
|
|
132
|
+
_polling_pid: Optional[int] = None
|
|
133
|
+
_state_lock = threading.Lock()
|
|
134
|
+
_formatter = logging.Formatter()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def buffer() -> Buffer:
|
|
138
|
+
return _buffer
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def set_buffer(value: Buffer) -> None:
|
|
142
|
+
"""For tests: a buffer with other bounds or a controllable clock."""
|
|
143
|
+
global _buffer
|
|
144
|
+
_buffer = value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def active() -> bool:
|
|
148
|
+
return _active
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def activate() -> None:
|
|
152
|
+
global _active, _polling_pid
|
|
153
|
+
_polling_pid = None
|
|
154
|
+
_active = True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def deactivate() -> None:
|
|
158
|
+
global _active, _polling_pid
|
|
159
|
+
_polling_pid = None
|
|
160
|
+
_active = False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def holding() -> bool:
|
|
164
|
+
"""Whether this process holds any traced line: the transport polls ingest
|
|
165
|
+
for log requests only then."""
|
|
166
|
+
try:
|
|
167
|
+
return _buffer.any()
|
|
168
|
+
except Exception:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def lookup(key: str, limit: int) -> List[str]:
|
|
173
|
+
try:
|
|
174
|
+
return _buffer.lookup(key, limit)
|
|
175
|
+
except Exception:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class LogHandler(logging.Handler):
|
|
180
|
+
"""Keeps every record logged under a trace, one entry per line, as
|
|
181
|
+
``[v1/<session>/<intent>/<hop>] LEVEL message``. Records with no current
|
|
182
|
+
trace are not kept."""
|
|
183
|
+
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
super().__init__(logging.NOTSET)
|
|
186
|
+
|
|
187
|
+
def handle(self, record: logging.LogRecord) -> bool:
|
|
188
|
+
# No handler lock and no filters: emit touches only the buffer, which
|
|
189
|
+
# has its own lock, and must cost nothing for an untraced record.
|
|
190
|
+
self.emit(record)
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
194
|
+
try:
|
|
195
|
+
# Our own warnings are never held: logging one while the
|
|
196
|
+
# transport is being built must not start building it again.
|
|
197
|
+
if not _active or record.name == "devbench":
|
|
198
|
+
return
|
|
199
|
+
trace = context.current_trace()
|
|
200
|
+
if trace is None:
|
|
201
|
+
return
|
|
202
|
+
for line in lines_for(str(trace), record.levelname, _message(record)):
|
|
203
|
+
_buffer.push(trace.key, line)
|
|
204
|
+
_start_polling()
|
|
205
|
+
except Exception:
|
|
206
|
+
# Never handleError(): that prints to the app's stderr.
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def install(logger: Optional[logging.Logger] = None) -> bool:
|
|
211
|
+
"""Adds the capturing handler to a logger (default: the root logger).
|
|
212
|
+
Idempotent. Returns True when it is in place."""
|
|
213
|
+
target = logger if logger is not None else logging.getLogger()
|
|
214
|
+
with _state_lock:
|
|
215
|
+
if not any(isinstance(h, LogHandler) for h in target.handlers):
|
|
216
|
+
target.addHandler(LogHandler())
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def lines_for(trace: str, level: Any, text: str) -> List[str]:
|
|
221
|
+
"""One held line per line of the message, each at most MAX_LINE_BYTES of
|
|
222
|
+
valid UTF-8. A multi-line message (an exception and its traceback) is
|
|
223
|
+
split so each line reaches triage on its own, blank lines dropped; at most
|
|
224
|
+
MAX_MESSAGE_LINES, the last saying how many were left out."""
|
|
225
|
+
prefix = "[%s] %s " % (trace, level) if level else "[%s] " % trace
|
|
226
|
+
if text.endswith("\n"):
|
|
227
|
+
text = text[:-1]
|
|
228
|
+
if "\n" not in text:
|
|
229
|
+
return [_bound(prefix + text)]
|
|
230
|
+
parts = [p[:-1] if p.endswith("\r") else p for p in text.split("\n")]
|
|
231
|
+
parts = [p for p in parts if p.strip()]
|
|
232
|
+
if not parts:
|
|
233
|
+
return []
|
|
234
|
+
if len(parts) > MAX_MESSAGE_LINES:
|
|
235
|
+
omitted = len(parts) - (MAX_MESSAGE_LINES - 1)
|
|
236
|
+
parts = parts[: MAX_MESSAGE_LINES - 1] + ["... (%d more lines)" % omitted]
|
|
237
|
+
return [_bound(prefix + p) for p in parts]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _message(record: logging.LogRecord) -> str:
|
|
241
|
+
# The record's own message, as Formatter.format builds it before
|
|
242
|
+
# adding the app's format string: the text, then the traceback.
|
|
243
|
+
text = record.getMessage()
|
|
244
|
+
if record.exc_info:
|
|
245
|
+
exc = _formatter.formatException(record.exc_info)
|
|
246
|
+
text = text + "\n" + exc if text else exc
|
|
247
|
+
elif record.exc_text:
|
|
248
|
+
text = text + "\n" + record.exc_text if text else record.exc_text
|
|
249
|
+
if record.stack_info:
|
|
250
|
+
text = text + "\n" + record.stack_info
|
|
251
|
+
return _valid(text)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _valid(text: str) -> str:
|
|
255
|
+
from .fingerprint import utf8
|
|
256
|
+
|
|
257
|
+
return utf8(text)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _bound(line: str) -> str:
|
|
261
|
+
data = line.encode("utf-8", "replace")
|
|
262
|
+
if len(data) <= MAX_LINE_BYTES:
|
|
263
|
+
return line
|
|
264
|
+
return data[:MAX_LINE_BYTES].decode("utf-8", "ignore")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _start_polling() -> None:
|
|
268
|
+
"""The first line this process holds starts the transport's flush
|
|
269
|
+
thread, so a process with nothing to report still polls for log requests
|
|
270
|
+
(a forked worker included: the PID is per process)."""
|
|
271
|
+
global _polling_pid
|
|
272
|
+
pid = os.getpid()
|
|
273
|
+
if _polling_pid == pid:
|
|
274
|
+
return
|
|
275
|
+
_polling_pid = pid
|
|
276
|
+
from . import _core
|
|
277
|
+
|
|
278
|
+
t = _core.transport()
|
|
279
|
+
start = getattr(t, "start", None)
|
|
280
|
+
if start is not None:
|
|
281
|
+
start()
|
|
282
|
+
|
devbench/py.typed
ADDED
|
File without changes
|
devbench/reporter.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Turns a failure into a report (the ``exception`` / ``handled_failure``
|
|
2
|
+
message of docs/SERVER_SDK_SPEC.md, as the Ruby and Go SDKs build it) and
|
|
3
|
+
hands it to the active transport.
|
|
4
|
+
|
|
5
|
+
Every failure here is swallowed. This is the one place where that is correct:
|
|
6
|
+
everywhere else it is the exact behaviour the product exists to find.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import collections
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Any, List, Optional
|
|
16
|
+
|
|
17
|
+
from . import context
|
|
18
|
+
from .fingerprint import utf8
|
|
19
|
+
from .version import PROTOCOL_VERSION
|
|
20
|
+
|
|
21
|
+
# docs/SERVER_SDK_SPEC.md, "Control socket — v1".
|
|
22
|
+
MAX_MESSAGE = 2000
|
|
23
|
+
MAX_HANDLED_MESSAGE = 500
|
|
24
|
+
MAX_FRAMES = 50
|
|
25
|
+
# Bounds one frame so 50 of them stay small.
|
|
26
|
+
MAX_FIELD = 1024
|
|
27
|
+
|
|
28
|
+
CONTEXTS = ("request", "job", "explicit")
|
|
29
|
+
|
|
30
|
+
# Status codes and framework control flow, not failures. Matched by name
|
|
31
|
+
# against the exception's class and its bases (module.QualName), so none of
|
|
32
|
+
# these packages needs to be importable. Most never reach a hook (Django,
|
|
33
|
+
# Flask and Starlette answer them before any error signal), but an app may
|
|
34
|
+
# pass one to capture_exception. Extend with devbench.ignore_exceptions.
|
|
35
|
+
DEFAULT_IGNORED = (
|
|
36
|
+
"django.http.response.Http404",
|
|
37
|
+
"django.core.exceptions.PermissionDenied",
|
|
38
|
+
"django.core.exceptions.SuspiciousOperation",
|
|
39
|
+
"django.core.exceptions.BadRequest",
|
|
40
|
+
"werkzeug.exceptions.HTTPException",
|
|
41
|
+
"starlette.exceptions.HTTPException",
|
|
42
|
+
"celery.exceptions.Retry",
|
|
43
|
+
"celery.exceptions.Ignore",
|
|
44
|
+
"celery.exceptions.Reject",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_MARK = "__devbench_reported__"
|
|
48
|
+
# Exceptions that refuse an attribute: remembered by id, bounded.
|
|
49
|
+
_UNMARKABLE_MAX = 1024
|
|
50
|
+
|
|
51
|
+
_ignored: List[str] = list(DEFAULT_IGNORED)
|
|
52
|
+
_unmarkable: "collections.OrderedDict[int, bool]" = collections.OrderedDict()
|
|
53
|
+
_unmarkable_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ignore_exceptions() -> List[str]:
|
|
57
|
+
"""The class names never reported (a list the app may extend)::
|
|
58
|
+
|
|
59
|
+
devbench.ignore_exceptions().append("myapp.errors.NotAuthorized")
|
|
60
|
+
"""
|
|
61
|
+
return _ignored
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def class_name(cls: type) -> str:
|
|
65
|
+
"""``module.QualName``; builtins by their plain name (``ValueError``)."""
|
|
66
|
+
module = getattr(cls, "__module__", None) or ""
|
|
67
|
+
qual = getattr(cls, "__qualname__", None) or getattr(cls, "__name__", "Exception")
|
|
68
|
+
return qual if module in ("builtins", "exceptions", "") else "%s.%s" % (module, qual)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ignored(error: BaseException) -> bool:
|
|
72
|
+
# Process control, not application failures: SystemExit,
|
|
73
|
+
# KeyboardInterrupt, GeneratorExit, asyncio.CancelledError.
|
|
74
|
+
if not isinstance(error, Exception):
|
|
75
|
+
return True
|
|
76
|
+
try:
|
|
77
|
+
names = {class_name(c) for c in type(error).__mro__}
|
|
78
|
+
return any(name in names for name in _ignored)
|
|
79
|
+
except Exception:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reported(error: BaseException) -> bool:
|
|
84
|
+
"""Whether any hook already sent this exception object."""
|
|
85
|
+
try:
|
|
86
|
+
if getattr(error, _MARK, False) is True:
|
|
87
|
+
return True
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
with _unmarkable_lock:
|
|
91
|
+
return id(error) in _unmarkable
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _mark(error: BaseException) -> None:
|
|
95
|
+
try:
|
|
96
|
+
setattr(error, _MARK, True)
|
|
97
|
+
if getattr(error, _MARK, False) is True:
|
|
98
|
+
return
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
with _unmarkable_lock:
|
|
102
|
+
_unmarkable[id(error)] = True
|
|
103
|
+
while len(_unmarkable) > _UNMARKABLE_MAX:
|
|
104
|
+
_unmarkable.popitem(last=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def capture(error: Any, context_name: str, handled: bool, symbol: Optional[str] = None) -> None:
|
|
108
|
+
"""Sends an ``exception`` report, once per exception object. Never
|
|
109
|
+
raises: this runs inside the app's own error paths, and anything raised
|
|
110
|
+
here would replace the application's exception."""
|
|
111
|
+
try:
|
|
112
|
+
if not isinstance(error, BaseException):
|
|
113
|
+
return
|
|
114
|
+
if reported(error) or ignored(error):
|
|
115
|
+
return
|
|
116
|
+
# Marked before sending, so a second hook reached while this one is
|
|
117
|
+
# still sending does not send it again.
|
|
118
|
+
_mark(error)
|
|
119
|
+
_emit(exception_payload(error, context_name, handled, symbol))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def report_handled(error: Any, symbol: str, reason: Optional[str] = None) -> None:
|
|
125
|
+
"""Records that a failure was handled here. Does not change behaviour."""
|
|
126
|
+
# Counted first, and regardless of whether anything after it works: the
|
|
127
|
+
# count is what the browser is told.
|
|
128
|
+
try:
|
|
129
|
+
context.ensure().handled += 1
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
try:
|
|
133
|
+
_emit(handled_payload(error, symbol, reason))
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def exception_payload(error: BaseException, context_name: str, handled: bool, symbol: Optional[str]) -> dict:
|
|
139
|
+
payload = {
|
|
140
|
+
"v": PROTOCOL_VERSION,
|
|
141
|
+
"kind": "exception",
|
|
142
|
+
"context": context_name if context_name in CONTEXTS else "explicit",
|
|
143
|
+
"handled": bool(handled),
|
|
144
|
+
"error": _safely(lambda: class_name(type(error))) or "Exception",
|
|
145
|
+
"message": _safely(lambda: truncate(_message(error), MAX_MESSAGE)),
|
|
146
|
+
"symbol": _safely(lambda: utf8(symbol) if symbol is not None else "") or "",
|
|
147
|
+
"frames": _safely(lambda: frames(error.__traceback__, _app_root())) or [],
|
|
148
|
+
"trace": _safely(lambda: _trace_text()),
|
|
149
|
+
"user": _safely(context.current_user),
|
|
150
|
+
}
|
|
151
|
+
return {k: v for k, v in payload.items() if v is not None}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def handled_payload(error: Any, symbol: Any, reason: Any) -> dict:
|
|
155
|
+
# Each field is read on its own: an exception whose __str__ raises still
|
|
156
|
+
# reports its class and site, which are the fingerprint.
|
|
157
|
+
payload = {
|
|
158
|
+
"v": PROTOCOL_VERSION,
|
|
159
|
+
"kind": "handled_failure",
|
|
160
|
+
"symbol": _safely(lambda: utf8(symbol)) if symbol is not None else None,
|
|
161
|
+
"error": _safely(lambda: class_name(type(error))),
|
|
162
|
+
"message": _safely(lambda: truncate(_message(error), MAX_HANDLED_MESSAGE)),
|
|
163
|
+
"reason": _safely(lambda: utf8(reason)) if reason is not None else None,
|
|
164
|
+
"trace": _safely(lambda: _trace_text()),
|
|
165
|
+
"user": _safely(context.current_user),
|
|
166
|
+
}
|
|
167
|
+
return {k: v for k, v in payload.items() if v is not None}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def frames(tb: Optional[TracebackType], root: Optional[str]) -> list:
|
|
171
|
+
"""A traceback as ``[{function, file, line}]``, innermost first, at most
|
|
172
|
+
MAX_FRAMES, with files under ``root`` made relative to it."""
|
|
173
|
+
window: "collections.deque" = collections.deque(maxlen=MAX_FRAMES)
|
|
174
|
+
while tb is not None:
|
|
175
|
+
window.append(tb)
|
|
176
|
+
tb = tb.tb_next
|
|
177
|
+
prefix = _root_prefix(root)
|
|
178
|
+
out = []
|
|
179
|
+
for item in reversed(window):
|
|
180
|
+
code = item.tb_frame.f_code
|
|
181
|
+
function = getattr(code, "co_qualname", None) or code.co_name
|
|
182
|
+
file = utf8(code.co_filename)
|
|
183
|
+
if prefix and file.startswith(prefix):
|
|
184
|
+
file = file[len(prefix):]
|
|
185
|
+
out.append({"function": _clip(utf8(function)), "file": _clip(file), "line": int(item.tb_lineno or 0)})
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def truncate(text: Any, limit: int) -> str:
|
|
190
|
+
"""At most ``limit`` characters, including the ellipsis."""
|
|
191
|
+
text = utf8(text)
|
|
192
|
+
return text[: limit - 1] + "…" if len(text) > limit else text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _message(error: Any) -> str:
|
|
196
|
+
return str(error)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _trace_text() -> Optional[str]:
|
|
200
|
+
trace = context.current_trace()
|
|
201
|
+
return str(trace) if trace is not None else None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _app_root() -> Optional[str]:
|
|
205
|
+
from . import _core
|
|
206
|
+
|
|
207
|
+
return _core.config().resolved_root()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _root_prefix(root: Optional[str]) -> Optional[str]:
|
|
211
|
+
if not root:
|
|
212
|
+
return None
|
|
213
|
+
return root if root.endswith(os.sep) else root + os.sep
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _clip(text: str) -> str:
|
|
217
|
+
return text[:MAX_FIELD] if len(text) > MAX_FIELD else text
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _safely(fn: Any) -> Any:
|
|
221
|
+
try:
|
|
222
|
+
return fn()
|
|
223
|
+
except Exception:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _emit(payload: dict) -> None:
|
|
228
|
+
from . import _core
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
_core.transport().deliver(payload)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|