xakki-log-python 0.4.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.
- log_python/__init__.py +160 -0
- log_python/config.py +144 -0
- log_python/const.py +41 -0
- log_python/context.py +575 -0
- log_python/formatters.py +217 -0
- log_python/handlers/__init__.py +1 -0
- log_python/handlers/redis.py +42 -0
- log_python/heartbeat.py +52 -0
- log_python/integrations/__init__.py +1 -0
- log_python/integrations/sqlalchemy.py +87 -0
- log_python/logtype.py +171 -0
- log_python/manager.py +394 -0
- log_python/py.typed +0 -0
- log_python/redactor.py +261 -0
- log_python/trace.py +504 -0
- xakki_log_python-0.4.0.dist-info/METADATA +275 -0
- xakki_log_python-0.4.0.dist-info/RECORD +19 -0
- xakki_log_python-0.4.0.dist-info/WHEEL +4 -0
- xakki_log_python-0.4.0.dist-info/licenses/LICENSE +674 -0
log_python/manager.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Core record-enrichment orchestration, ported from `LogManager::appendContext()`
|
|
2
|
+
(reference `src/LogManager.php`, spec `docs/LoggingRules.md` / `docs/ContextFieldNaming.md`,
|
|
3
|
+
both re-verified 2026-08-13). This is the single behavioural hook every other phase feeds
|
|
4
|
+
into: `LogManager.append_context()` takes a level/message/context triple and returns an
|
|
5
|
+
enriched context dict plus the (possibly truncated) message, ready for a formatter
|
|
6
|
+
to render onto the wire. Handler/formatter wiring lives in `log_python.setup()`; this
|
|
7
|
+
module remains framework-agnostic.
|
|
8
|
+
|
|
9
|
+
## Operation order (verified against `LogManager::appendContext()`, line-by-line)
|
|
10
|
+
|
|
11
|
+
1. Extract the `LOGGER_SKIP_TRACE` ('skipTrace') marker, cast it through the SAME
|
|
12
|
+
bool-truthiness convention as every other bool-shaped context field
|
|
13
|
+
(`context.py`'s `_cast_bool()` / `CONTEXT_FALSE_VALUES`, wired via
|
|
14
|
+
`trace.should_skip_trace()`), and POP the marker key so it never reaches the wire —
|
|
15
|
+
this mirrors a fix landed concurrently on the PHP side (uncommitted as of this port,
|
|
16
|
+
`LogManager::appendContext()` using `contextCastBool()`); see `mapping.md`.
|
|
17
|
+
2. Extract `context['exception']` when it is a real exception instance (and only then —
|
|
18
|
+
a non-exception value under that key is left alone, exactly like the reference's
|
|
19
|
+
`instanceof \\Throwable` guard).
|
|
20
|
+
3. `log_type` classification: only when the caller did not already supply one — `logger`
|
|
21
|
+
by default, promoted to `exception` when an exception is present and the ambient
|
|
22
|
+
`LogType.current()` origin is still the default.
|
|
23
|
+
4. Context type-coercion (`ContextTypeCorrector`) THEN key-based redaction
|
|
24
|
+
(`Redactor.should_redact_key`) — in that order, and ONLY in that order. Reference
|
|
25
|
+
`contextTypeCorrector()` checks `shouldRedactKey()` BEFORE casting a given key, so a
|
|
26
|
+
sensitive key is masked outright and never cast; running redaction first and coercion
|
|
27
|
+
second here would be a no-op for THAT ordering, but running coercion first, redaction
|
|
28
|
+
AFTER (this port's approach), and never re-coercing the masked `"***"` string produces
|
|
29
|
+
the identical final result — because coercion and (key-based) redaction are mutually
|
|
30
|
+
exclusive per key, "coerce every key, then overwrite the sensitive ones with the mask"
|
|
31
|
+
equals "check-then-branch per key". The one order that is WRONG is masking a value
|
|
32
|
+
BEFORE coercion could see it: `contextCastBool("***")` on a `password_bool` key would
|
|
33
|
+
come out `True` — a masked value turned back into a coerced one, a real type-stability
|
|
34
|
+
break the tests in `test_manager.py` pin explicitly (a key that is both sensitive AND
|
|
35
|
+
suffix-typed must serialize as the string `"***"`, never a cast value).
|
|
36
|
+
5. Exception-derived fields, added to the ALREADY-coerced-and-redacted dict — so these
|
|
37
|
+
fields bypass coercion and (key-based) redaction entirely, exactly like the reference
|
|
38
|
+
(`appendContext()` populates them AFTER its own `contextTypeCorrector()` call). See
|
|
39
|
+
"Exception chain wire shape" below for the field list.
|
|
40
|
+
6. `message_len` — the length of the ORIGINAL, untruncated message. Order matters: the
|
|
41
|
+
reference computes this BEFORE the message is ever truncated (truncation only happens
|
|
42
|
+
at the very end, on the RETURNED string, never stored back into `context`).
|
|
43
|
+
7. Caller `file:line` (+ conditionally, call-site `trace`) — ONLY when `'file'` is not
|
|
44
|
+
already set (i.e., no exception path ran, or it left `'file'` falsy). Delegates to
|
|
45
|
+
`TraceCapture.capture()`, which already embeds the "no trace below WARNING" cutoff.
|
|
46
|
+
8. Memory fields, only when `allow_memory` is on.
|
|
47
|
+
9. `request_id` — always (re)stamped, unconditionally, from the ambient context var.
|
|
48
|
+
10. Return `(message[:message_limit], enriched_context)`.
|
|
49
|
+
|
|
50
|
+
## Public-API divergence from the reference (documented, not an oversight)
|
|
51
|
+
|
|
52
|
+
PHP's `appendContext(Level $level, string|Stringable $message, array &$context): string`
|
|
53
|
+
mutates `$context` by reference and returns only the truncated message. This port instead
|
|
54
|
+
returns `(message, new_context)` — the SAME "never mutate the input, return a new dict"
|
|
55
|
+
convention `ContextTypeCorrector.correct()` and `Redactor.redact()` already established in
|
|
56
|
+
phases 2/3, kept consistent rather than reintroducing PHP's by-reference out-param pattern
|
|
57
|
+
into idiomatic Python.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import contextvars
|
|
63
|
+
import math
|
|
64
|
+
import sys
|
|
65
|
+
import tracemalloc
|
|
66
|
+
import uuid
|
|
67
|
+
from collections.abc import Mapping
|
|
68
|
+
from typing import Final
|
|
69
|
+
|
|
70
|
+
from log_python.config import PylogConfig
|
|
71
|
+
from log_python.const import LOGGER_MEMORY, LOGGER_MEMORY_PEAK, LOGGER_SKIP_TRACE
|
|
72
|
+
from log_python.context import ContextTypeCorrector
|
|
73
|
+
from log_python.logtype import LogType
|
|
74
|
+
from log_python.redactor import Redactor
|
|
75
|
+
from log_python.trace import TraceCapture, exception_class_name, exception_origin, should_skip_trace
|
|
76
|
+
|
|
77
|
+
# The full `__cause__`/`__context__` chain, coalesced into ONE string field by
|
|
78
|
+
# `TraceCapture.format_exception_chain()` (phase 4) — satisfies spec §6.1's stated intent
|
|
79
|
+
# ("full chain via getPrevious()") alongside the flat, ONE-level `exception_prev*` fields
|
|
80
|
+
# below, which reproduce the reference's ACTUAL (narrower) behaviour 1:1. Deliberately not
|
|
81
|
+
# a `LOGGER_*` constant in `const.py`: that module's contract is "byte-for-byte identical to
|
|
82
|
+
# the PHP reference" (its own docstring, and `test_const.py` asserts each entry against
|
|
83
|
+
# `src/const.php` verbatim) — there is nothing in the PHP source for this field to match,
|
|
84
|
+
# since it has no PHP counterpart at all. Chosen not to collide with any of the seven
|
|
85
|
+
# PHP-defined exception field names.
|
|
86
|
+
EXCEPTION_CHAIN_FIELD: Final[str] = "exception_chain"
|
|
87
|
+
|
|
88
|
+
_REQUEST_ID: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar("pylog_request_id", default=None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_or_create_request_id() -> str:
|
|
92
|
+
"""Return the current context's request id, generating one on first use.
|
|
93
|
+
|
|
94
|
+
Reference `LogManager::getOrCreateRequestId()`: a UUID (or an incoming
|
|
95
|
+
`X-Request-ID`), pinned to string in the type-coercion table because it may be
|
|
96
|
+
all-digits and must never flip to int (see `context.py::_RESERVED_RULES`).
|
|
97
|
+
|
|
98
|
+
Format: `str(uuid.uuid4())` — a standard RFC 4122 v4 UUID string (lowercase,
|
|
99
|
+
hyphenated), the same textual shape as Laravel's `Str::uuid()`. This port does not
|
|
100
|
+
read an inbound `X-Request-ID` header itself (there is no framework-agnostic Python
|
|
101
|
+
equivalent of PHP's `$_SERVER` superglobal to read from) — a web/worker integration
|
|
102
|
+
calls `set_request_id()` explicitly instead, e.g. from middleware.
|
|
103
|
+
|
|
104
|
+
Scoping divergence (deliberate): the reference's id lives in `$_SERVER`, a single
|
|
105
|
+
process-global value — correct for PHP-FPM's one-request-per-process model, but a
|
|
106
|
+
Python process may serve many concurrent requests/tasks on the SAME process (threads,
|
|
107
|
+
asyncio). A bare global here would leak one request's id into another's log lines
|
|
108
|
+
running concurrently. `contextvars.ContextVar` gives each thread/async-task context
|
|
109
|
+
its own value instead, which is a closer match to the spec's own description of
|
|
110
|
+
`request_id` as "stable within one request" than a literal process-global port would
|
|
111
|
+
have been.
|
|
112
|
+
"""
|
|
113
|
+
current = _REQUEST_ID.get()
|
|
114
|
+
if current:
|
|
115
|
+
return current
|
|
116
|
+
new_id = str(uuid.uuid4())
|
|
117
|
+
_REQUEST_ID.set(new_id)
|
|
118
|
+
return new_id
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def set_request_id(request_id: str) -> None:
|
|
122
|
+
"""Seed the current context's request id explicitly — e.g. from an inbound
|
|
123
|
+
`X-Request-ID` header in a web middleware. No PHP method maps onto this directly;
|
|
124
|
+
the reference reads `$_SERVER['HTTP_X_REQUEST_ID']` inline inside
|
|
125
|
+
`getOrCreateRequestId()`, which has no framework-agnostic Python equivalent — this is
|
|
126
|
+
the explicit seam a caller uses instead.
|
|
127
|
+
"""
|
|
128
|
+
_REQUEST_ID.set(request_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def reset_request_id() -> None:
|
|
132
|
+
"""Clear the current context's request id so the next unit of work gets a fresh one.
|
|
133
|
+
|
|
134
|
+
Reference `LogManager::resetRequestId()` — the Laravel `JobProcessing` listener
|
|
135
|
+
(`LaraLogServiceProvider`) becomes this explicit function, which a worker calls
|
|
136
|
+
between jobs so a long-running process doesn't stamp every job with the same id.
|
|
137
|
+
"""
|
|
138
|
+
_REQUEST_ID.set(None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _safe_str(value: object) -> str:
|
|
142
|
+
"""Best-effort `str()`, never raising — used for the log `message` itself, which the
|
|
143
|
+
public API accepts as `object` specifically so a hostile value (broken `__str__`)
|
|
144
|
+
degrades to a placeholder rather than blowing up the logging path.
|
|
145
|
+
"""
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
return value
|
|
148
|
+
try:
|
|
149
|
+
return str(value)
|
|
150
|
+
except Exception:
|
|
151
|
+
return "<unrenderable message>"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _safe_copy_context(context: Mapping[str, object] | None) -> dict[str, object]:
|
|
155
|
+
"""Shallow-copy the caller's context into a fresh dict this function owns — never
|
|
156
|
+
mutates the caller's mapping. Wrapped because a hostile custom `Mapping` (broken
|
|
157
|
+
`__iter__`/`.items()`) must not be able to raise out of the logging path.
|
|
158
|
+
"""
|
|
159
|
+
if context is None:
|
|
160
|
+
return {}
|
|
161
|
+
try:
|
|
162
|
+
return dict(context)
|
|
163
|
+
except Exception:
|
|
164
|
+
return {}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _exception_code(exc: BaseException) -> int:
|
|
168
|
+
"""Python-side derivation of PHP's `(int) $e->getCode()`. Every PHP `Throwable` has a
|
|
169
|
+
`getCode()` (default `0`); Python's `BaseException` has no universal numeric-code
|
|
170
|
+
attribute. This reads an optional `.code` attribute (several stdlib exceptions —
|
|
171
|
+
`SystemExit`, some driver exceptions — already use that name) and coerces it the same
|
|
172
|
+
tolerant way the reference's `(int)` cast would, falling back to `0` for anything
|
|
173
|
+
absent, non-numeric, or a raising property.
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
code = getattr(exc, "code", None)
|
|
177
|
+
except Exception:
|
|
178
|
+
return 0
|
|
179
|
+
if isinstance(code, bool):
|
|
180
|
+
return int(code)
|
|
181
|
+
if isinstance(code, int):
|
|
182
|
+
return code
|
|
183
|
+
if isinstance(code, float):
|
|
184
|
+
return int(code) if math.isfinite(code) else 0
|
|
185
|
+
if isinstance(code, str):
|
|
186
|
+
try:
|
|
187
|
+
return int(code.strip())
|
|
188
|
+
except ValueError:
|
|
189
|
+
return 0
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _previous_exception(exc: BaseException) -> BaseException | None:
|
|
194
|
+
"""The exception this port treats as PHP's `$e->getPrevious()` — owner decision:
|
|
195
|
+
`__cause__` (explicit `raise X from Y`) takes priority over `__context__` (implicit
|
|
196
|
+
chaining), and an explicit `raise X from None` (`__suppress_context__`) means no
|
|
197
|
+
previous exception at all, matching Python's own traceback-printing rule and
|
|
198
|
+
`TraceCapture.format_exception_chain()`'s identical priority (phase 4, `trace.py`).
|
|
199
|
+
Wrapped because reading dunder attributes off a hostile custom exception subclass
|
|
200
|
+
(e.g. `__cause__`/`__suppress_context__` as raising properties) must not raise out of
|
|
201
|
+
the logging path.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
cause = exc.__cause__
|
|
205
|
+
if cause is not None:
|
|
206
|
+
return cause
|
|
207
|
+
context = exc.__context__
|
|
208
|
+
if context is not None and not exc.__suppress_context__:
|
|
209
|
+
return context
|
|
210
|
+
except Exception:
|
|
211
|
+
return None
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _peak_memory_bytes() -> int | None:
|
|
216
|
+
"""Process-wide peak resident set size, in bytes — the closest POSIX analogue of PHP's
|
|
217
|
+
`memory_get_peak_usage()`, but NOT the same quantity: PHP's figure is the Zend engine's
|
|
218
|
+
own allocator peak (userland PHP object/array memory), while `ru_maxrss` is the OS's
|
|
219
|
+
peak RSS for the whole process (interpreter, C extensions, everything). Same field
|
|
220
|
+
name (`LOGGER_MEMORY_PEAK`), same JSON type (int), genuinely different semantics —
|
|
221
|
+
documented here and in `mapping.md`, not papered over.
|
|
222
|
+
|
|
223
|
+
`resource` is POSIX-only (no Windows build) and imported lazily, inside the `try`,
|
|
224
|
+
specifically so there is nothing to reassign in an `except` branch — the
|
|
225
|
+
strict no-suppression rule in `AGENTS.md` rules out the usual conditional
|
|
226
|
+
import plus assignment-suppression shape,
|
|
227
|
+
and this local-import form needs no such fallback assignment at all.
|
|
228
|
+
|
|
229
|
+
`ru_maxrss` unit differs by platform: kibibytes on Linux, bytes on macOS (`darwin`) —
|
|
230
|
+
normalized to bytes here so the field means the same thing regardless of host OS,
|
|
231
|
+
matching the PHP fleet's byte-denominated field.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
import resource
|
|
235
|
+
except ImportError:
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
239
|
+
except Exception:
|
|
240
|
+
return None
|
|
241
|
+
if sys.platform == "darwin":
|
|
242
|
+
return int(peak)
|
|
243
|
+
return int(peak) * 1024
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _current_traced_memory_bytes() -> int | None:
|
|
247
|
+
"""Current Python-allocated memory, in bytes, from `tracemalloc` — ONLY when tracing
|
|
248
|
+
is already active. This library never calls `tracemalloc.start()` itself (a real,
|
|
249
|
+
measurable hot-path tax for every allocation in the process); when tracing is off the
|
|
250
|
+
`LOGGER_MEMORY` field is simply omitted from the record rather than emitted as a
|
|
251
|
+
placeholder, which is fine for the field-type-stability contract (an ABSENT key is not
|
|
252
|
+
a second JSON type for the same key, only a present-with-wrong-type value would be).
|
|
253
|
+
"""
|
|
254
|
+
if not tracemalloc.is_tracing():
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
current, _peak = tracemalloc.get_traced_memory()
|
|
258
|
+
except Exception:
|
|
259
|
+
return None
|
|
260
|
+
return current
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class LogManager:
|
|
264
|
+
"""Precompiled, reusable enrichment orchestrator (mirrors `Redactor` /
|
|
265
|
+
`ContextTypeCorrector` / `TraceCapture` — build once per process from a `PylogConfig`,
|
|
266
|
+
reuse for its lifetime; no per-call recompilation or env re-reads on the hot path).
|
|
267
|
+
|
|
268
|
+
`append_context()` is the single public entry point, faithfully porting
|
|
269
|
+
`LogManager::appendContext()` — see the module docstring for the verified operation
|
|
270
|
+
order. This class does not touch handler/formatter wiring or install process
|
|
271
|
+
hooks; it is a pure enrichment function.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
__slots__ = ("_allow_memory", "_corrector", "_message_limit", "_redactor", "_trace")
|
|
275
|
+
|
|
276
|
+
def __init__(self, config: PylogConfig) -> None:
|
|
277
|
+
self._redactor = Redactor.from_config(config)
|
|
278
|
+
self._corrector = ContextTypeCorrector.from_config(config)
|
|
279
|
+
self._trace = TraceCapture.from_config(config, self._redactor)
|
|
280
|
+
self._message_limit = config.message_limit
|
|
281
|
+
self._allow_memory = config.allow_memory
|
|
282
|
+
|
|
283
|
+
def append_context(self, level: int, message: object, context: Mapping[str, object] | None = None) -> tuple[str, dict[str, object]]:
|
|
284
|
+
"""Enrich `context` for a record at `level` (a stdlib `logging` level int) with
|
|
285
|
+
`message`. Returns `(message_truncated_to_message_limit, enriched_context)` — see
|
|
286
|
+
the module docstring for exactly what gets added and in what order.
|
|
287
|
+
|
|
288
|
+
Must never raise: `message` and `context` are both treated as untrusted input
|
|
289
|
+
(hostile `__str__`/`__repr__`, a `Mapping` whose `.items()` raises, a broken
|
|
290
|
+
exception object, ...). Every raise-prone step ported into this method is wrapped
|
|
291
|
+
individually (see the private helpers above); this is the one OUTER last-resort
|
|
292
|
+
net for anything those miss — it still returns a well-formed, minimally-enriched
|
|
293
|
+
result rather than letting an exception escape the logging path.
|
|
294
|
+
"""
|
|
295
|
+
safe_message = _safe_str(message)
|
|
296
|
+
try:
|
|
297
|
+
return self._append_context(level, safe_message, context)
|
|
298
|
+
except Exception:
|
|
299
|
+
return safe_message[: self._message_limit], {
|
|
300
|
+
"log_type": LogType.LOGGER,
|
|
301
|
+
"request_id": get_or_create_request_id(),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
def _append_context(self, level: int, message: str, context: Mapping[str, object] | None) -> tuple[str, dict[str, object]]:
|
|
305
|
+
working = _safe_copy_context(context)
|
|
306
|
+
|
|
307
|
+
# 1. LOGGER_SKIP_TRACE: read, bool-cast, then POP so the marker key never reaches
|
|
308
|
+
# coercion/redaction/the wire — mirrors the reference's `unset()` of the same key.
|
|
309
|
+
skip_trace = should_skip_trace(working)
|
|
310
|
+
working.pop(LOGGER_SKIP_TRACE, None)
|
|
311
|
+
|
|
312
|
+
# 2. Extract a real exception only — a non-Throwable-shaped value under
|
|
313
|
+
# 'exception' is left in place, exactly like `isset(...) && ... instanceof`.
|
|
314
|
+
raw_exc = working.get("exception")
|
|
315
|
+
exc: BaseException | None = raw_exc if isinstance(raw_exc, BaseException) else None
|
|
316
|
+
if exc is not None:
|
|
317
|
+
del working["exception"]
|
|
318
|
+
|
|
319
|
+
# 3. log_type classification — only when the caller did not already supply one.
|
|
320
|
+
if working.get("log_type") is None:
|
|
321
|
+
log_type = LogType.current()
|
|
322
|
+
if exc is not None and log_type == LogType.LOGGER:
|
|
323
|
+
log_type = LogType.EXCEPTION
|
|
324
|
+
working["log_type"] = log_type
|
|
325
|
+
|
|
326
|
+
# 4. Coercion, THEN key-based redaction — see the module docstring for why this
|
|
327
|
+
# order (and not the reverse) is the one that matches the reference.
|
|
328
|
+
enriched = self._coerce_and_redact(working)
|
|
329
|
+
|
|
330
|
+
# 5. Exception-derived fields, added AFTER coercion/redaction — bypass both,
|
|
331
|
+
# exactly like the reference (these are library-computed, already-typed values).
|
|
332
|
+
if exc is not None:
|
|
333
|
+
enriched["exception"] = exception_class_name(exc)
|
|
334
|
+
enriched["exception_message"] = self._redactor.redact_value(_safe_str(exc))
|
|
335
|
+
enriched["exception_code"] = _exception_code(exc)
|
|
336
|
+
enriched["file"] = exception_origin(exc)
|
|
337
|
+
if not skip_trace:
|
|
338
|
+
enriched["trace"] = self._trace.render_exception_trace(exc)
|
|
339
|
+
enriched[EXCEPTION_CHAIN_FIELD] = self._trace.format_exception_chain(exc)
|
|
340
|
+
prev = _previous_exception(exc)
|
|
341
|
+
if prev is not None:
|
|
342
|
+
enriched["exception_prev"] = exception_class_name(prev)
|
|
343
|
+
# Owner-flagged content divergence from the reference: PHP writes
|
|
344
|
+
# `$e->getPrevious()->getMessage()` verbatim, unredacted. Spec §6.1 itself
|
|
345
|
+
# calls the exception message "suspect" (PII/log-injection) and offers
|
|
346
|
+
# "apply the same redactor as for the request body" as an option; this
|
|
347
|
+
# port takes that option (value-shape `redact_value()`, best-effort, same
|
|
348
|
+
# mechanism `_render_arg()` already applies to trace arguments) rather
|
|
349
|
+
# than reproducing the unredacted PHP behaviour. Field NAME and TYPE stay
|
|
350
|
+
# 1:1 with PHP (still a plain string) — only string CONTENT can differ,
|
|
351
|
+
# and only when the message happens to match a Bearer/JWT/URL-credential/
|
|
352
|
+
# key=value shape. Reported to the team-lead as vetoable.
|
|
353
|
+
enriched["exception_prev_message"] = self._redactor.redact_value(_safe_str(prev))
|
|
354
|
+
enriched["exception_prev_file"] = exception_origin(prev)
|
|
355
|
+
enriched["exception_prev_code"] = _exception_code(prev)
|
|
356
|
+
if not skip_trace:
|
|
357
|
+
enriched["exception_prev_trace"] = self._trace.render_exception_trace(prev)
|
|
358
|
+
|
|
359
|
+
# 6. message_len: the length of the message BEFORE truncation — computed here,
|
|
360
|
+
# truncation happens only in the tuple this method returns, at the very end.
|
|
361
|
+
enriched["message_len"] = len(message)
|
|
362
|
+
|
|
363
|
+
# 7. Caller file:line (+ conditionally, call-site trace) — only when 'file' is not
|
|
364
|
+
# already set (exception path already set it above, when exc is not None).
|
|
365
|
+
if not enriched.get("file"):
|
|
366
|
+
captured = self._trace.capture(level)
|
|
367
|
+
enriched["file"] = captured.file
|
|
368
|
+
if not skip_trace and not enriched.get("trace") and captured.trace is not None:
|
|
369
|
+
enriched["trace"] = captured.trace
|
|
370
|
+
|
|
371
|
+
# 8. Memory fields, opt-in only.
|
|
372
|
+
if self._allow_memory:
|
|
373
|
+
peak = _peak_memory_bytes()
|
|
374
|
+
if peak is not None:
|
|
375
|
+
enriched[LOGGER_MEMORY_PEAK] = peak
|
|
376
|
+
current = _current_traced_memory_bytes()
|
|
377
|
+
if current is not None:
|
|
378
|
+
enriched[LOGGER_MEMORY] = current
|
|
379
|
+
|
|
380
|
+
# 9. request_id — always (re)stamped, unconditionally.
|
|
381
|
+
enriched["request_id"] = get_or_create_request_id()
|
|
382
|
+
|
|
383
|
+
# 10. Truncate the RETURNED message only; message_len above already reflects the
|
|
384
|
+
# untruncated length.
|
|
385
|
+
return message[: self._message_limit], enriched
|
|
386
|
+
|
|
387
|
+
def _coerce_and_redact(self, context: Mapping[str, object]) -> dict[str, object]:
|
|
388
|
+
"""`ContextTypeCorrector.correct()` THEN a key-based redaction overlay. See the
|
|
389
|
+
module docstring's point 4 for why "coerce everything, then overwrite sensitive
|
|
390
|
+
keys with the mask" is equivalent to (and not "backwards" from) the reference's
|
|
391
|
+
per-key "check redaction before casting" order.
|
|
392
|
+
"""
|
|
393
|
+
coerced = self._corrector.correct(context)
|
|
394
|
+
return {key: (Redactor.MASK if self._redactor.should_redact_key(key) else value) for key, value in coerced.items()}
|
log_python/py.typed
ADDED
|
File without changes
|
log_python/redactor.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Credential / secret redaction at the process boundary.
|
|
2
|
+
|
|
3
|
+
Python port of the reference `src/Redactor.php` (xakki/laralog, spec
|
|
4
|
+
`docs/LoggingRules.md` §2 / §2.1). Two complementary modes:
|
|
5
|
+
|
|
6
|
+
- **by key**: for keyed data (context fields, dict entries) — match the FIELD
|
|
7
|
+
NAME against a needle list, case-insensitive substring.
|
|
8
|
+
- **by value**: for positional / free-form data (stack-trace arguments,
|
|
9
|
+
exception args, log messages) where no field name is available — match the
|
|
10
|
+
VALUE itself against Bearer/Basic-auth headers, JWTs, and
|
|
11
|
+
``key=value``/``key: value`` assignments whose key looks sensitive.
|
|
12
|
+
|
|
13
|
+
Built-in needles are always active; extra needles (e.g. from
|
|
14
|
+
``PylogConfig.redact``) are merged ON TOP of them and never replace them.
|
|
15
|
+
|
|
16
|
+
This module has zero third-party dependencies (stdlib ``re`` only), per the
|
|
17
|
+
project's zero-runtime-dependency contract for the core.
|
|
18
|
+
|
|
19
|
+
Deliberate divergences from ``Redactor.php`` (the reference has neither):
|
|
20
|
+
|
|
21
|
+
- ``redact_value`` also matches a needle key wrapped in a quote (``"key":
|
|
22
|
+
"value"`` JSON, ``'key' => 'value'`` PHP-array-style) — logging a raw
|
|
23
|
+
request/response body is routine, so leaving quoted values unredacted was a
|
|
24
|
+
live gap worth closing in the port even though the reference doesn't.
|
|
25
|
+
- ``redact_value`` also masks the password component of a
|
|
26
|
+
``scheme://user:password@host`` URL (host and user are left intact — they
|
|
27
|
+
are diagnostically useful and not secret).
|
|
28
|
+
|
|
29
|
+
Known, accepted limitation (same in both languages, not a bug to "discover"
|
|
30
|
+
later): a bare token with no key and no Bearer/Basic/JWT shape — e.g.
|
|
31
|
+
``["Authorization", "Bearer", raw_token]`` split across list elements — is
|
|
32
|
+
never redacted. Catching that would need heuristics over unrelated sibling
|
|
33
|
+
values with unacceptable false-positive risk.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from collections.abc import Iterable, Mapping
|
|
40
|
+
from typing import ClassVar, Final
|
|
41
|
+
|
|
42
|
+
from log_python.config import PylogConfig
|
|
43
|
+
|
|
44
|
+
MASK: Final[str] = "***"
|
|
45
|
+
|
|
46
|
+
# High-signal needles only — substring match, so overly-broad tokens (`auth`, `card`,
|
|
47
|
+
# `pin`, `session`) are deliberately excluded to avoid masking legit fields like
|
|
48
|
+
# `cardinality` / `shipping` / `author`. Add narrow project needles via
|
|
49
|
+
# `PylogConfig.redact`. Ported 1:1 from `Redactor.php::BUILTIN` (14 needles,
|
|
50
|
+
# verified against the reference 2026-08-13).
|
|
51
|
+
BUILTIN_NEEDLES: Final[tuple[str, ...]] = (
|
|
52
|
+
"password",
|
|
53
|
+
"passwd",
|
|
54
|
+
"secret",
|
|
55
|
+
"token",
|
|
56
|
+
"authorization",
|
|
57
|
+
"api_key",
|
|
58
|
+
"apikey",
|
|
59
|
+
"access_key",
|
|
60
|
+
"private_key",
|
|
61
|
+
"credential",
|
|
62
|
+
"cookie",
|
|
63
|
+
"cvv",
|
|
64
|
+
"card_number",
|
|
65
|
+
"cardnumber",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Bearer / Basic auth header values. PHP: `/\b(bearer|basic)\s+[A-Za-z0-9._\-\/+=]{8,}/i`.
|
|
69
|
+
# The token-body quantifier is bounded (PHP's `{8,}` is not) as a defensive cap against
|
|
70
|
+
# pathological input sizes. This is safe specifically because it is a TRAILING atom with
|
|
71
|
+
# nothing after it in the pattern: a bound here can only shorten what gets matched for the
|
|
72
|
+
# `.search()` boolean check, never prevent the match outright (as long as >=8 eligible chars
|
|
73
|
+
# exist anywhere, some prefix of them up to the cap still matches). And since a match masks
|
|
74
|
+
# the WHOLE value (see `redact_value` below), a truncated match still fully masks the value
|
|
75
|
+
# — there is no truncation-leak and no false-non-match. Verified 2026-08-13.
|
|
76
|
+
_BEARER_RE: Final[re.Pattern[str]] = re.compile(r"\b(?:bearer|basic)\s+[A-Za-z0-9._\-/+=]{8,4096}", re.IGNORECASE)
|
|
77
|
+
|
|
78
|
+
# JWT: three base64url segments. PHP: `/\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/`.
|
|
79
|
+
# Unlike _BEARER_RE, each segment quantifier here is immediately followed by a required
|
|
80
|
+
# literal ('.' or end of string) — so a bound is NOT safe: a payload segment longer than the
|
|
81
|
+
# bound (real JWTs can carry many claims/roles well past 4096 chars) has no position within
|
|
82
|
+
# the bound where the next char is the required '.', so the WHOLE regex silently fails to
|
|
83
|
+
# match — a false non-match that leaks the entire token, worse than any truncation. Verified
|
|
84
|
+
# 2026-08-13 (a >4096-char payload segment was confirmed to defeat the bounded version).
|
|
85
|
+
# Restored unbounded to match the PHP reference exactly.
|
|
86
|
+
_JWT_RE: Final[re.Pattern[str]] = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
|
|
87
|
+
|
|
88
|
+
# URL-embedded credentials: `scheme://user:password@host`. Not present in Redactor.php (see
|
|
89
|
+
# module docstring). Only the password is masked; scheme/user/host stay intact for
|
|
90
|
+
# diagnostics.
|
|
91
|
+
_URL_CREDENTIALS_RE: Final[re.Pattern[str]] = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)(?P<user>[^:/?#\s@]+):(?P<password>[^@/?#\s]+)@")
|
|
92
|
+
|
|
93
|
+
# Recursion guard for Redactor.redact(). No realistic log payload is this deep; hitting the
|
|
94
|
+
# limit means either a pathological or self-referential structure, so we fail safe (mask)
|
|
95
|
+
# rather than risk leaking an un-inspected branch.
|
|
96
|
+
_MAX_DEPTH: Final[int] = 50
|
|
97
|
+
_CIRCULAR_PLACEHOLDER: Final[str] = "<circular>"
|
|
98
|
+
|
|
99
|
+
# Hot-path guard for Redactor._redact(): these four types can never carry a secret, so the
|
|
100
|
+
# generic str()-scan fallback is skipped for them entirely. Deliberately a small explicit
|
|
101
|
+
# allow-list (not "isinstance of a numeric ABC" or similar) — Decimal/complex/etc. are NOT
|
|
102
|
+
# listed here on purpose and still go through the str() scan, per project instruction.
|
|
103
|
+
_SKIP_STR_SCAN_TYPES: Final[tuple[type, ...]] = (type(None), bool, int, float)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Redactor:
|
|
107
|
+
"""Precompiled, reusable redaction helper.
|
|
108
|
+
|
|
109
|
+
Construct once (needle list + value-pattern regexes are compiled at
|
|
110
|
+
construction, not per call — this sits on the hot logging path) and reuse
|
|
111
|
+
for the process lifetime.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
MASK: ClassVar[str] = MASK
|
|
115
|
+
|
|
116
|
+
__slots__ = ("_kv_sub_re", "_needles")
|
|
117
|
+
|
|
118
|
+
def __init__(self, needles: Iterable[str] = ()) -> None:
|
|
119
|
+
"""Build the merged, deduplicated, lowercased needle list and precompile regexes.
|
|
120
|
+
|
|
121
|
+
Built-in needles always win a place; ``needles`` (e.g. config-supplied
|
|
122
|
+
extras) are merged ON TOP, never replacing them.
|
|
123
|
+
"""
|
|
124
|
+
merged: list[str] = []
|
|
125
|
+
seen: set[str] = set()
|
|
126
|
+
for needle in (*BUILTIN_NEEDLES, *needles):
|
|
127
|
+
lowered = needle.strip().lower()
|
|
128
|
+
if lowered and lowered not in seen:
|
|
129
|
+
seen.add(lowered)
|
|
130
|
+
merged.append(lowered)
|
|
131
|
+
self._needles: tuple[str, ...] = tuple(merged)
|
|
132
|
+
|
|
133
|
+
# key=value / key: value / key=>value where key is one of the needles (built-in or
|
|
134
|
+
# config-supplied). PHP: match `/(needles)\s*[=:]\s*\S+/i` then replace
|
|
135
|
+
# `/((?:needles)\s*[=:]\s*)\S+/i` with `$1***`. Python `re.sub` already no-ops when
|
|
136
|
+
# there is no match, so a single substitution call covers both PHP steps.
|
|
137
|
+
#
|
|
138
|
+
# The trailing value atom is intentionally UNBOUNDED (`\S+`, not `\S{1,N}`): it is a
|
|
139
|
+
# simple greedy atom at the end of the pattern with nothing after it to fail on, so it
|
|
140
|
+
# cannot backtrack catastrophically — a bound here only truncated the capture, leaving
|
|
141
|
+
# the tail of any secret longer than the bound as literal, unmasked characters right
|
|
142
|
+
# after the mask (a real leak, not just a theoretical one — verified 2026-08-13).
|
|
143
|
+
#
|
|
144
|
+
# Divergence from Redactor.php (documented in the module docstring): an optional quote
|
|
145
|
+
# right after the needle (closing quote of a JSON/PHP-array key, e.g. `"password":` /
|
|
146
|
+
# `'token' =>`) and an optional opening quote at the start of the value are both
|
|
147
|
+
# tolerated, and a quoted value is matched — and replaced, together with its closing
|
|
148
|
+
# quote — as a single unit, so no dangling quote is ever left behind.
|
|
149
|
+
alternation = "|".join(re.escape(needle) for needle in self._needles)
|
|
150
|
+
self._kv_sub_re: re.Pattern[str] = re.compile(
|
|
151
|
+
rf"((?:{alternation})[\"']?\s*(?:=>|[=:])\s*)(?:\"[^\"]*\"|'[^']*'|\S+)",
|
|
152
|
+
re.IGNORECASE,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_config(cls, config: PylogConfig) -> Redactor:
|
|
157
|
+
"""Build a Redactor whose extra needles come from `PylogConfig.redact`."""
|
|
158
|
+
return cls(config.redact)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def needles(self) -> tuple[str, ...]:
|
|
162
|
+
"""The merged, deduplicated, lowercased needle list (built-ins + config extras)."""
|
|
163
|
+
return self._needles
|
|
164
|
+
|
|
165
|
+
def should_redact_key(self, key: str) -> bool:
|
|
166
|
+
"""True if `key` (case-insensitive substring match) contains any needle."""
|
|
167
|
+
lowered = key.lower()
|
|
168
|
+
return any(needle in lowered for needle in self._needles)
|
|
169
|
+
|
|
170
|
+
def redact_value(self, value: str) -> str:
|
|
171
|
+
"""Best-effort redaction of a positional (key-less) string value.
|
|
172
|
+
|
|
173
|
+
Masks the whole value when it looks like a Bearer/Basic auth header or a JWT;
|
|
174
|
+
otherwise masks the password component of any `scheme://user:password@host` URL
|
|
175
|
+
(Python-only addition, see module docstring) and the value part of any
|
|
176
|
+
`needle=...`/`needle: ...`/`needle=>...` assignment (optionally quoted) found
|
|
177
|
+
inside it, leaving the rest of the string untouched.
|
|
178
|
+
"""
|
|
179
|
+
if _BEARER_RE.search(value):
|
|
180
|
+
return MASK
|
|
181
|
+
if _JWT_RE.search(value):
|
|
182
|
+
return MASK
|
|
183
|
+
value = _URL_CREDENTIALS_RE.sub(rf"\g<scheme>\g<user>:{MASK}@", value)
|
|
184
|
+
return self._kv_sub_re.sub(r"\1" + MASK, value)
|
|
185
|
+
|
|
186
|
+
def redact_pair(self, key: str, value: object) -> object:
|
|
187
|
+
"""Redact a single key/value pair: mask outright on a sensitive key, else walk the value."""
|
|
188
|
+
if self.should_redact_key(key):
|
|
189
|
+
return MASK
|
|
190
|
+
return self.redact(value)
|
|
191
|
+
|
|
192
|
+
def redact(self, value: object) -> object:
|
|
193
|
+
"""Recursively redact a nested structure (dict/list/tuple/set/scalar/arbitrary object).
|
|
194
|
+
|
|
195
|
+
- Mapping: keys matching a needle are masked outright; other values recurse.
|
|
196
|
+
- list/tuple: every element recurses (list stays a list, tuple stays a tuple).
|
|
197
|
+
- set/frozenset: every element recurses (type preserved); redacted elements stay
|
|
198
|
+
hashable (str/tuple/frozenset), so this can never raise.
|
|
199
|
+
- str: scanned with `redact_value` (Bearer/JWT/URL-credentials/key=value patterns).
|
|
200
|
+
- bytes/bytearray: decoded (`errors="replace"`, never raises) and scanned the same
|
|
201
|
+
way; returned as the redacted `str` if anything changed, else as the original
|
|
202
|
+
object unchanged (callers keep their type when there was nothing to redact).
|
|
203
|
+
- None/bool/int/float: returned unchanged — these can never carry a secret, so the
|
|
204
|
+
`str()` scan below is skipped for them as a hot-path guard.
|
|
205
|
+
- anything else (dataclass, exception, custom class, ...): `str(obj)` is scanned the
|
|
206
|
+
same way; the redacted **string** is returned only if redaction actually changed
|
|
207
|
+
it, otherwise the original object is returned unchanged so structure/type survive
|
|
208
|
+
the overwhelmingly common non-secret case. `str(obj)` is allowed to raise (a
|
|
209
|
+
broken `__str__`) — caught and failed safe to MASK, since a logging library must
|
|
210
|
+
never raise from the logging path.
|
|
211
|
+
|
|
212
|
+
Guards against unbounded recursion (depth cap, fails safe to MASK) and
|
|
213
|
+
self-referential containers (identity-tracked, fails safe to a circular marker).
|
|
214
|
+
"""
|
|
215
|
+
return self._redact(value, seen=frozenset(), depth=0)
|
|
216
|
+
|
|
217
|
+
def _redact(self, value: object, *, seen: frozenset[int], depth: int) -> object:
|
|
218
|
+
if depth >= _MAX_DEPTH:
|
|
219
|
+
return MASK
|
|
220
|
+
if isinstance(value, str):
|
|
221
|
+
return self.redact_value(value)
|
|
222
|
+
if isinstance(value, bytes | bytearray):
|
|
223
|
+
decoded = value.decode("utf-8", errors="replace")
|
|
224
|
+
redacted_text = self.redact_value(decoded)
|
|
225
|
+
return redacted_text if redacted_text != decoded else value
|
|
226
|
+
if isinstance(value, Mapping):
|
|
227
|
+
obj_id = id(value)
|
|
228
|
+
if obj_id in seen:
|
|
229
|
+
return _CIRCULAR_PLACEHOLDER
|
|
230
|
+
next_seen = seen | {obj_id}
|
|
231
|
+
return {
|
|
232
|
+
key: (MASK if isinstance(key, str) and self.should_redact_key(key) else self._redact(item, seen=next_seen, depth=depth + 1))
|
|
233
|
+
for key, item in value.items()
|
|
234
|
+
}
|
|
235
|
+
if isinstance(value, list | tuple):
|
|
236
|
+
obj_id = id(value)
|
|
237
|
+
if obj_id in seen:
|
|
238
|
+
return _CIRCULAR_PLACEHOLDER
|
|
239
|
+
next_seen = seen | {obj_id}
|
|
240
|
+
items = [self._redact(item, seen=next_seen, depth=depth + 1) for item in value]
|
|
241
|
+
return items if isinstance(value, list) else tuple(items)
|
|
242
|
+
if isinstance(value, set | frozenset):
|
|
243
|
+
obj_id = id(value)
|
|
244
|
+
if obj_id in seen:
|
|
245
|
+
return _CIRCULAR_PLACEHOLDER
|
|
246
|
+
next_seen = seen | {obj_id}
|
|
247
|
+
# Elements of a set/frozenset are already hashable by construction, and every
|
|
248
|
+
# branch above returns a hashable type (str, tuple, frozenset, scalar) for a
|
|
249
|
+
# hashable input, so the rebuilt set/frozenset can never raise TypeError here.
|
|
250
|
+
redacted_items = {self._redact(item, seen=next_seen, depth=depth + 1) for item in value}
|
|
251
|
+
return redacted_items if isinstance(value, set) else frozenset(redacted_items)
|
|
252
|
+
if isinstance(value, _SKIP_STR_SCAN_TYPES):
|
|
253
|
+
return value
|
|
254
|
+
try:
|
|
255
|
+
text = str(value)
|
|
256
|
+
except Exception:
|
|
257
|
+
# A logging library must not raise from the logging path just because some
|
|
258
|
+
# object's __str__ is broken — fail safe to MASK rather than propagate.
|
|
259
|
+
return MASK
|
|
260
|
+
redacted_text = self.redact_value(text)
|
|
261
|
+
return redacted_text if redacted_text != text else value
|