logleaf 1.0.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.
- logleaf/__init__.py +24 -0
- logleaf/_core.py +344 -0
- logleaf/_models.py +64 -0
- logleaf/exceptions.py +134 -0
- logleaf/experimental/__init__.py +5 -0
- logleaf/experimental/prototype.py +232 -0
- logleaf/formatting.py +92 -0
- logleaf/logger.py +226 -0
- logleaf/prototype.py +8 -0
- logleaf/shared.py +113 -0
- logleaf/sinks.py +103 -0
- logleaf-1.0.0.dist-info/METADATA +226 -0
- logleaf-1.0.0.dist-info/RECORD +16 -0
- logleaf-1.0.0.dist-info/WHEEL +5 -0
- logleaf-1.0.0.dist-info/licenses/LICENSE +21 -0
- logleaf-1.0.0.dist-info/top_level.txt +1 -0
logleaf/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Background logging. Stable entry points: log, Logger, BoundLogger and statistics."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from ._models import LoggerStats, OutputStats
|
|
6
|
+
from .logger import BoundLogger, Logger
|
|
7
|
+
from .shared import log
|
|
8
|
+
|
|
9
|
+
# Keep old explicit/star imports working without loading the prototype for
|
|
10
|
+
# ordinary `from logleaf import log` users.
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .experimental import BatchLogger, Stats
|
|
13
|
+
|
|
14
|
+
__all__ = ["log", "Logger", "BoundLogger", "LoggerStats", "OutputStats", "BatchLogger", "Stats"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str) -> Any:
|
|
18
|
+
if name in ("BatchLogger", "Stats"):
|
|
19
|
+
from . import experimental
|
|
20
|
+
|
|
21
|
+
value = getattr(experimental, name)
|
|
22
|
+
globals()[name] = value
|
|
23
|
+
return value
|
|
24
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
logleaf/_core.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Queue admission, writer lifecycle, output failure and loss accounting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from time import time_ns as clock_ns
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._models import LoggerStats, OutputStats, _Event, _ExceptionEvent, _Output
|
|
14
|
+
from .formatting import LEVELS, Formatter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Core:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
outputs: list[_Output],
|
|
21
|
+
formatter: Formatter,
|
|
22
|
+
timestamp_mode: str,
|
|
23
|
+
capacity: int,
|
|
24
|
+
batch_size: int,
|
|
25
|
+
flush_interval: float,
|
|
26
|
+
drop_report_interval: float,
|
|
27
|
+
poll_interval: float = 0.005,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.outputs = outputs
|
|
30
|
+
self.formatter = formatter
|
|
31
|
+
self.timestamp_mode = timestamp_mode
|
|
32
|
+
self.capacity = capacity
|
|
33
|
+
self.batch_size = batch_size
|
|
34
|
+
self.flush_interval = flush_interval
|
|
35
|
+
self.drop_report_interval = drop_report_interval
|
|
36
|
+
self.poll_interval = poll_interval
|
|
37
|
+
self.pid = os.getpid()
|
|
38
|
+
self.minimum = min((output.level for output in outputs), default=100)
|
|
39
|
+
self.available_minimum = self.minimum
|
|
40
|
+
self.condition = threading.Condition(threading.Lock())
|
|
41
|
+
self.queue: deque[_Event] = deque()
|
|
42
|
+
self.closing = False
|
|
43
|
+
self.worker_error: str | None = None
|
|
44
|
+
self.format_errors = 0
|
|
45
|
+
self.last_format_error: str | None = None
|
|
46
|
+
self.accepted = self.written = self.failed = self.partial = self.processed = 0
|
|
47
|
+
self.high = self.rejected = 0
|
|
48
|
+
self.drops: dict[int, int] = dict.fromkeys(LEVELS, 0)
|
|
49
|
+
started_ns = clock_ns()
|
|
50
|
+
for output in outputs:
|
|
51
|
+
output.report_since_ns = started_ns
|
|
52
|
+
self.thread = threading.Thread(target=self._run, name="logleaf-writer", daemon=True)
|
|
53
|
+
try:
|
|
54
|
+
self.thread.start()
|
|
55
|
+
except BaseException:
|
|
56
|
+
for output in outputs:
|
|
57
|
+
output.target.close()
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
def check_process(self) -> None:
|
|
61
|
+
# Check BEFORE locks: a forked child may inherit a permanently locked condition.
|
|
62
|
+
if os.getpid() != self.pid:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"create a new Logger inside each child process; inherited Logger unsafe"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def submit(self, event: _Event) -> bool:
|
|
68
|
+
with self.condition:
|
|
69
|
+
if self.closing:
|
|
70
|
+
raise RuntimeError("logger is closed")
|
|
71
|
+
if self.worker_error or event.level < self.available_minimum:
|
|
72
|
+
self.rejected += 1
|
|
73
|
+
return False
|
|
74
|
+
pending = self.accepted - self.processed
|
|
75
|
+
if pending >= self.capacity:
|
|
76
|
+
self.drops[event.level] += 1
|
|
77
|
+
return False
|
|
78
|
+
self.queue.append(event)
|
|
79
|
+
self.accepted += 1
|
|
80
|
+
if pending >= self.high:
|
|
81
|
+
self.high = pending + 1
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def snapshot(self) -> LoggerStats:
|
|
85
|
+
self.check_process()
|
|
86
|
+
with self.condition:
|
|
87
|
+
dropped = sum(self.drops.values())
|
|
88
|
+
return LoggerStats(
|
|
89
|
+
accepted=self.accepted,
|
|
90
|
+
written=self.written,
|
|
91
|
+
failed=self.failed,
|
|
92
|
+
partial=self.partial,
|
|
93
|
+
pending=self.accepted - self.processed,
|
|
94
|
+
dropped=dropped,
|
|
95
|
+
dropped_by_level={LEVELS[k]: v for k, v in self.drops.items()},
|
|
96
|
+
rejected_unavailable=self.rejected,
|
|
97
|
+
high_watermark=self.high,
|
|
98
|
+
outputs={
|
|
99
|
+
output.name: OutputStats(
|
|
100
|
+
output.written,
|
|
101
|
+
output.failed,
|
|
102
|
+
sum(output.reported.values()),
|
|
103
|
+
dropped - sum(output.reported.values()),
|
|
104
|
+
output.error,
|
|
105
|
+
)
|
|
106
|
+
for output in self.outputs
|
|
107
|
+
},
|
|
108
|
+
worker_error=self.worker_error,
|
|
109
|
+
format_errors=self.format_errors,
|
|
110
|
+
last_format_error=self.last_format_error,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def can_capture_exception(self) -> bool:
|
|
114
|
+
# Exception-only preflight avoids expensive snapshots on an already full
|
|
115
|
+
# queue. submit() rechecks admission after capture; no capacity is reserved.
|
|
116
|
+
with self.condition:
|
|
117
|
+
if self.closing:
|
|
118
|
+
raise RuntimeError("logger is closed")
|
|
119
|
+
if self.worker_error or 40 < self.available_minimum:
|
|
120
|
+
self.rejected += 1
|
|
121
|
+
return False
|
|
122
|
+
if self.accepted - self.processed >= self.capacity:
|
|
123
|
+
self.drops[40] += 1
|
|
124
|
+
return False
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def close(self, timeout: float | None) -> None:
|
|
128
|
+
self.check_process()
|
|
129
|
+
if timeout is not None and (
|
|
130
|
+
type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout < 0
|
|
131
|
+
):
|
|
132
|
+
raise ValueError("timeout must be nonnegative and finite, or None")
|
|
133
|
+
with self.condition:
|
|
134
|
+
self.closing = True
|
|
135
|
+
self.condition.notify_all()
|
|
136
|
+
self.thread.join(timeout)
|
|
137
|
+
if self.thread.is_alive():
|
|
138
|
+
raise TimeoutError("writer is still draining; close() may be retried")
|
|
139
|
+
errors = [f"{o.name}: {o.error}" for o in self.outputs if o.error]
|
|
140
|
+
if self.worker_error:
|
|
141
|
+
errors.append(self.worker_error)
|
|
142
|
+
if self.last_format_error:
|
|
143
|
+
errors.append("format: " + self.last_format_error)
|
|
144
|
+
if errors:
|
|
145
|
+
raise RuntimeError("log output failed; inspect stats: " + "; ".join(errors))
|
|
146
|
+
|
|
147
|
+
def _fail_output(self, output: _Output, error: Exception) -> None:
|
|
148
|
+
with self.condition:
|
|
149
|
+
if output.error is None:
|
|
150
|
+
output.error = f"{type(error).__name__}: {error}"
|
|
151
|
+
self.available_minimum = min(
|
|
152
|
+
(target.level for target in self.outputs if target.error is None), default=100
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _flush(self) -> None:
|
|
156
|
+
for output in self.outputs:
|
|
157
|
+
if output.error is None:
|
|
158
|
+
try:
|
|
159
|
+
output.target.flush()
|
|
160
|
+
except Exception as error:
|
|
161
|
+
self._fail_output(output, error)
|
|
162
|
+
|
|
163
|
+
def _record(self, event: _Event) -> dict[str, Any]:
|
|
164
|
+
stamp = event.timestamp_ns if event.timestamp_ns is not None else clock_ns()
|
|
165
|
+
record = {
|
|
166
|
+
"timestamp": self.formatter.timestamp(stamp),
|
|
167
|
+
"timestamp_mode": self.timestamp_mode,
|
|
168
|
+
"level": LEVELS[event.level],
|
|
169
|
+
"event": event.message,
|
|
170
|
+
**(event.context or {}),
|
|
171
|
+
**event.fields,
|
|
172
|
+
}
|
|
173
|
+
if isinstance(event, _ExceptionEvent) and event.exception is not None:
|
|
174
|
+
snapshot = event.exception
|
|
175
|
+
record["_log_hub"] = {
|
|
176
|
+
"kind": "exception",
|
|
177
|
+
"type": snapshot.type_name,
|
|
178
|
+
"message": snapshot.message,
|
|
179
|
+
"traceback": snapshot.render(),
|
|
180
|
+
}
|
|
181
|
+
return record
|
|
182
|
+
|
|
183
|
+
def _format_failure(self, error: Exception) -> None:
|
|
184
|
+
with self.condition:
|
|
185
|
+
self.format_errors += 1
|
|
186
|
+
self.last_format_error = f"{type(error).__name__}: {error}"
|
|
187
|
+
|
|
188
|
+
def _write_single(self, batch: list[_Event] | deque[_Event]) -> None:
|
|
189
|
+
# Admission already applied this only output's level. Avoid constructing
|
|
190
|
+
# per-output index and delivery arrays for the common file-only case.
|
|
191
|
+
output = self.outputs[0]
|
|
192
|
+
written = 0
|
|
193
|
+
if output.error is None:
|
|
194
|
+
lines = []
|
|
195
|
+
for event in batch:
|
|
196
|
+
try:
|
|
197
|
+
lines.append(self.formatter.render(self._record(event), output.style))
|
|
198
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
199
|
+
self._format_failure(error)
|
|
200
|
+
try:
|
|
201
|
+
if lines:
|
|
202
|
+
output.target.write(lines)
|
|
203
|
+
written = len(lines)
|
|
204
|
+
except Exception as error:
|
|
205
|
+
self._fail_output(output, error)
|
|
206
|
+
failed = len(batch) - written
|
|
207
|
+
with self.condition:
|
|
208
|
+
output.written += written
|
|
209
|
+
output.failed += failed
|
|
210
|
+
self.written += written
|
|
211
|
+
self.failed += failed
|
|
212
|
+
self.processed += len(batch)
|
|
213
|
+
|
|
214
|
+
def _write_batch(self, batch: list[_Event] | deque[_Event]) -> None:
|
|
215
|
+
if len(self.outputs) == 1:
|
|
216
|
+
self._write_single(batch)
|
|
217
|
+
return
|
|
218
|
+
records = []
|
|
219
|
+
for event in batch:
|
|
220
|
+
records.append(self._record(event))
|
|
221
|
+
delivered = [0] * len(batch)
|
|
222
|
+
targets = [0] * len(batch)
|
|
223
|
+
for output in self.outputs:
|
|
224
|
+
indices = [i for i, event in enumerate(batch) if event.level >= output.level]
|
|
225
|
+
if not indices:
|
|
226
|
+
continue
|
|
227
|
+
for i in indices:
|
|
228
|
+
targets[i] += 1
|
|
229
|
+
ok = False
|
|
230
|
+
valid_indices = []
|
|
231
|
+
if output.error is None:
|
|
232
|
+
lines = []
|
|
233
|
+
for i in indices:
|
|
234
|
+
try:
|
|
235
|
+
lines.append(self.formatter.render(records[i], output.style))
|
|
236
|
+
valid_indices.append(i)
|
|
237
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
238
|
+
self._format_failure(error)
|
|
239
|
+
try:
|
|
240
|
+
if lines:
|
|
241
|
+
output.target.write(lines)
|
|
242
|
+
ok = True
|
|
243
|
+
except Exception as error:
|
|
244
|
+
self._fail_output(output, error)
|
|
245
|
+
with self.condition:
|
|
246
|
+
if ok:
|
|
247
|
+
output.written += len(valid_indices)
|
|
248
|
+
output.failed += len(indices) - len(valid_indices)
|
|
249
|
+
else:
|
|
250
|
+
output.failed += len(indices)
|
|
251
|
+
if ok:
|
|
252
|
+
for i in valid_indices:
|
|
253
|
+
delivered[i] += 1
|
|
254
|
+
# Aggregate outside the admission lock so a batch cannot lengthen the
|
|
255
|
+
# producer's critical section by hundreds of Python loop iterations.
|
|
256
|
+
written = sum(count > 0 for count in delivered)
|
|
257
|
+
failed = len(batch) - written
|
|
258
|
+
partial = sum(0 < count < target for count, target in zip(delivered, targets))
|
|
259
|
+
with self.condition:
|
|
260
|
+
self.written += written
|
|
261
|
+
self.failed += failed
|
|
262
|
+
self.partial += partial
|
|
263
|
+
self.processed += len(batch)
|
|
264
|
+
|
|
265
|
+
def _report_drops(self) -> None:
|
|
266
|
+
# No producer-side clock reads in worker mode, including the overflow path.
|
|
267
|
+
# These timestamps bound accounting windows, not individual drop times.
|
|
268
|
+
with self.condition:
|
|
269
|
+
totals = self.drops.copy()
|
|
270
|
+
until_ns = clock_ns()
|
|
271
|
+
for output in self.outputs:
|
|
272
|
+
if output.error is not None:
|
|
273
|
+
continue
|
|
274
|
+
counts = {LEVELS[k]: totals[k] - output.reported[k] for k in LEVELS}
|
|
275
|
+
count = sum(counts.values())
|
|
276
|
+
if not count:
|
|
277
|
+
continue
|
|
278
|
+
record = {
|
|
279
|
+
"timestamp": self.formatter.timestamp(until_ns),
|
|
280
|
+
"timestamp_mode": "worker",
|
|
281
|
+
"level": "WARNING",
|
|
282
|
+
"event": "日志队列溢出",
|
|
283
|
+
"_log_hub": {
|
|
284
|
+
"kind": "queue_overflow",
|
|
285
|
+
"dropped": count,
|
|
286
|
+
"dropped_by_level": counts,
|
|
287
|
+
"window_start": self.formatter.timestamp(output.report_since_ns),
|
|
288
|
+
"window_end": self.formatter.timestamp(until_ns),
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
try:
|
|
292
|
+
# Bypasses queue and severity filter; flush before marking reported.
|
|
293
|
+
output.target.write([self.formatter.render(record, output.style)])
|
|
294
|
+
output.target.flush()
|
|
295
|
+
except Exception as error:
|
|
296
|
+
self._fail_output(output, error)
|
|
297
|
+
else:
|
|
298
|
+
with self.condition:
|
|
299
|
+
output.reported = totals.copy()
|
|
300
|
+
output.report_since_ns = until_ns
|
|
301
|
+
|
|
302
|
+
def _run(self) -> None:
|
|
303
|
+
next_flush = time.monotonic() + self.flush_interval
|
|
304
|
+
next_report = time.monotonic() + self.drop_report_interval
|
|
305
|
+
try:
|
|
306
|
+
while True:
|
|
307
|
+
with self.condition:
|
|
308
|
+
self.condition.wait_for(
|
|
309
|
+
lambda: bool(self.queue) or self.closing,
|
|
310
|
+
timeout=max(
|
|
311
|
+
0,
|
|
312
|
+
min(
|
|
313
|
+
self.poll_interval, min(next_flush, next_report) - time.monotonic()
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
if len(self.queue) <= self.batch_size:
|
|
318
|
+
batch, self.queue = self.queue, deque()
|
|
319
|
+
else:
|
|
320
|
+
batch = [self.queue.popleft() for _ in range(self.batch_size)]
|
|
321
|
+
closing = self.closing and not self.queue
|
|
322
|
+
if batch:
|
|
323
|
+
self._write_batch(batch)
|
|
324
|
+
now = time.monotonic()
|
|
325
|
+
if closing or now >= next_report:
|
|
326
|
+
self._report_drops()
|
|
327
|
+
next_report = time.monotonic() + self.drop_report_interval
|
|
328
|
+
if closing or now >= next_flush:
|
|
329
|
+
self._flush()
|
|
330
|
+
next_flush = time.monotonic() + self.flush_interval
|
|
331
|
+
if closing:
|
|
332
|
+
break
|
|
333
|
+
except BaseException as error:
|
|
334
|
+
with self.condition:
|
|
335
|
+
self.worker_error = f"{type(error).__name__}: {error}"
|
|
336
|
+
self.failed += self.accepted - self.processed
|
|
337
|
+
self.processed = self.accepted
|
|
338
|
+
self.queue.clear()
|
|
339
|
+
finally:
|
|
340
|
+
for output in self.outputs:
|
|
341
|
+
try:
|
|
342
|
+
output.target.close()
|
|
343
|
+
except Exception as error:
|
|
344
|
+
self._fail_output(output, error)
|
logleaf/_models.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared event envelopes and statistics; no queue or I/O behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .exceptions import ExceptionSnapshot
|
|
8
|
+
from .formatting import LEVELS
|
|
9
|
+
from .sinks import ConsoleOutput, FileOutput
|
|
10
|
+
|
|
11
|
+
Scalar = str | int | float | bool | None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class OutputStats:
|
|
16
|
+
written: int
|
|
17
|
+
failed: int
|
|
18
|
+
dropped_reported: int
|
|
19
|
+
dropped_unreported: int
|
|
20
|
+
error: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class LoggerStats:
|
|
25
|
+
accepted: int
|
|
26
|
+
written: int
|
|
27
|
+
failed: int
|
|
28
|
+
partial: int
|
|
29
|
+
pending: int
|
|
30
|
+
dropped: int
|
|
31
|
+
dropped_by_level: dict[str, int]
|
|
32
|
+
rejected_unavailable: int
|
|
33
|
+
high_watermark: int
|
|
34
|
+
outputs: dict[str, OutputStats]
|
|
35
|
+
worker_error: str | None
|
|
36
|
+
format_errors: int
|
|
37
|
+
last_format_error: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class _Event:
|
|
42
|
+
level: int
|
|
43
|
+
message: str
|
|
44
|
+
fields: dict[str, Scalar]
|
|
45
|
+
timestamp_ns: int | None
|
|
46
|
+
context: dict[str, Scalar] | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class _ExceptionEvent(_Event):
|
|
51
|
+
exception: ExceptionSnapshot | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _Output:
|
|
56
|
+
name: str
|
|
57
|
+
target: FileOutput | ConsoleOutput
|
|
58
|
+
level: int
|
|
59
|
+
style: str
|
|
60
|
+
written: int = 0
|
|
61
|
+
failed: int = 0
|
|
62
|
+
error: str | None = None
|
|
63
|
+
reported: dict[int, int] = field(default_factory=lambda: dict.fromkeys(LEVELS, 0))
|
|
64
|
+
report_since_ns: int = 0
|
logleaf/exceptions.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Detached exception snapshots: no traceback frames, exception objects or locals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
MAX_FRAMES = 64
|
|
9
|
+
MAX_NODES = 64
|
|
10
|
+
MAX_DEPTH = 8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _text(value: object) -> str:
|
|
14
|
+
try:
|
|
15
|
+
return str.__str__(str(value)) # Discard attributes on a returned str subclass.
|
|
16
|
+
except Exception:
|
|
17
|
+
return "<exception text unavailable>"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Frame:
|
|
22
|
+
filename: str
|
|
23
|
+
line: int
|
|
24
|
+
function: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ExceptionSnapshot:
|
|
29
|
+
type_name: str
|
|
30
|
+
message: str
|
|
31
|
+
frames: tuple[Frame, ...]
|
|
32
|
+
notes: tuple[str, ...]
|
|
33
|
+
previous: ExceptionSnapshot | None
|
|
34
|
+
relation: str | None
|
|
35
|
+
children: tuple[ExceptionSnapshot, ...]
|
|
36
|
+
omitted_frames: int = 0
|
|
37
|
+
omitted_children: int = 0
|
|
38
|
+
truncated_chain: bool = False
|
|
39
|
+
|
|
40
|
+
def render(self) -> str:
|
|
41
|
+
"""Worker-only string assembly. Source files are never read."""
|
|
42
|
+
lines: list[str] = []
|
|
43
|
+
if self.previous is not None:
|
|
44
|
+
lines.append(self.previous.render())
|
|
45
|
+
lines.append(
|
|
46
|
+
"The above exception was the direct cause of the following exception:"
|
|
47
|
+
if self.relation == "cause"
|
|
48
|
+
else "During handling of the above exception, another exception occurred:"
|
|
49
|
+
)
|
|
50
|
+
if self.truncated_chain:
|
|
51
|
+
lines.append("[exception chain truncated: cycle or snapshot limit]")
|
|
52
|
+
if self.frames:
|
|
53
|
+
lines.append("Traceback (most recent call last):")
|
|
54
|
+
if self.omitted_frames:
|
|
55
|
+
lines.append(f" [... {self.omitted_frames} earlier frames omitted]")
|
|
56
|
+
for frame in self.frames:
|
|
57
|
+
lines.append(f' File "{frame.filename}", line {frame.line}, in {frame.function}')
|
|
58
|
+
lines.append(f"{self.type_name}: {self.message}")
|
|
59
|
+
lines.extend(self.notes)
|
|
60
|
+
for index, child in enumerate(self.children, 1):
|
|
61
|
+
lines.append(f" + Exception group member {index}:")
|
|
62
|
+
lines.extend(" " + line for line in child.render().splitlines())
|
|
63
|
+
if self.omitted_children:
|
|
64
|
+
lines.append(f" [... {self.omitted_children} exception group members omitted]")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def capture_exception(error: BaseException) -> ExceptionSnapshot:
|
|
69
|
+
"""Capture descriptions now without retaining application-owned objects.
|
|
70
|
+
|
|
71
|
+
Limits bound stored frame/node counts, not string sizes or capture time.
|
|
72
|
+
str(error) necessarily executes on the caller; ordinary logs never use this.
|
|
73
|
+
"""
|
|
74
|
+
seen: set[int] = set()
|
|
75
|
+
|
|
76
|
+
def capture(current: BaseException, depth: int) -> ExceptionSnapshot:
|
|
77
|
+
seen.add(id(current))
|
|
78
|
+
frames: deque[Frame] = deque(maxlen=MAX_FRAMES)
|
|
79
|
+
count = 0
|
|
80
|
+
tb = current.__traceback__
|
|
81
|
+
while tb is not None:
|
|
82
|
+
code = tb.tb_frame.f_code
|
|
83
|
+
frames.append(Frame(str(code.co_filename), tb.tb_lineno, str(code.co_name)))
|
|
84
|
+
count += 1
|
|
85
|
+
tb = tb.tb_next
|
|
86
|
+
if isinstance(current, SyntaxError):
|
|
87
|
+
if isinstance(current.filename, str) and type(current.lineno) is int:
|
|
88
|
+
frames.append(Frame(str(current.filename), current.lineno, "<syntax>"))
|
|
89
|
+
count += 1
|
|
90
|
+
kind = type(current)
|
|
91
|
+
type_name = f"{_text(kind.__module__)}.{_text(kind.__qualname__)}"
|
|
92
|
+
message = _text(current)
|
|
93
|
+
try:
|
|
94
|
+
notes = current.__notes__
|
|
95
|
+
except Exception:
|
|
96
|
+
notes = ()
|
|
97
|
+
# Freeze notes too: callers may mutate their original list after logging.
|
|
98
|
+
notes = tuple(_text(note) for note in notes) if isinstance(notes, (list, tuple)) else ()
|
|
99
|
+
previous = None
|
|
100
|
+
relation = None
|
|
101
|
+
truncated = False
|
|
102
|
+
candidate = current.__cause__
|
|
103
|
+
if candidate is not None:
|
|
104
|
+
relation = "cause"
|
|
105
|
+
elif not current.__suppress_context__:
|
|
106
|
+
candidate = current.__context__
|
|
107
|
+
relation = "context" if candidate is not None else None
|
|
108
|
+
if candidate is not None:
|
|
109
|
+
if id(candidate) in seen or depth >= MAX_DEPTH or len(seen) >= MAX_NODES:
|
|
110
|
+
truncated = True
|
|
111
|
+
else:
|
|
112
|
+
previous = capture(candidate, depth + 1)
|
|
113
|
+
children = []
|
|
114
|
+
omitted = 0
|
|
115
|
+
if isinstance(current, BaseExceptionGroup):
|
|
116
|
+
for child in current.exceptions:
|
|
117
|
+
if id(child) in seen or depth >= MAX_DEPTH or len(seen) >= MAX_NODES:
|
|
118
|
+
omitted += 1
|
|
119
|
+
else:
|
|
120
|
+
children.append(capture(child, depth + 1))
|
|
121
|
+
return ExceptionSnapshot(
|
|
122
|
+
type_name,
|
|
123
|
+
message,
|
|
124
|
+
tuple(frames),
|
|
125
|
+
notes,
|
|
126
|
+
previous,
|
|
127
|
+
relation,
|
|
128
|
+
tuple(children),
|
|
129
|
+
max(0, count - MAX_FRAMES),
|
|
130
|
+
omitted,
|
|
131
|
+
truncated,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return capture(error, 0)
|