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/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