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/formatters.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Wire and developer-facing formatters for stdlib :mod:`logging`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from log_python.config import PylogConfig
|
|
13
|
+
from log_python.context import get_bound_context
|
|
14
|
+
from log_python.manager import LogManager
|
|
15
|
+
from log_python.redactor import Redactor
|
|
16
|
+
|
|
17
|
+
DEFAULT_MAX_BYTES: Final[int] = 1400
|
|
18
|
+
_ELLIPSIS: Final[str] = "…"
|
|
19
|
+
_REQUIRED_EXTRA_KEYS: Final[tuple[str, ...]] = ("app_name", "app_env", "app_ver", "log_ver", "tier")
|
|
20
|
+
_LEVELS: Final[tuple[tuple[int, str, int], ...]] = (
|
|
21
|
+
(60, "emergency", 600),
|
|
22
|
+
(55, "alert", 550),
|
|
23
|
+
(logging.CRITICAL, "critical", 500),
|
|
24
|
+
(logging.ERROR, "error", 400),
|
|
25
|
+
(logging.WARNING, "warning", 300),
|
|
26
|
+
(25, "notice", 250),
|
|
27
|
+
(logging.INFO, "info", 200),
|
|
28
|
+
(logging.DEBUG, "debug", 100),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _level(levelno: int) -> tuple[str, int]:
|
|
33
|
+
for threshold, name, number in _LEVELS:
|
|
34
|
+
if levelno >= threshold:
|
|
35
|
+
return name, number
|
|
36
|
+
return "debug", 100
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_json_value(value: object, redactor: Redactor) -> object:
|
|
40
|
+
try:
|
|
41
|
+
redacted = redactor.redact(value)
|
|
42
|
+
json.dumps(redacted, ensure_ascii=False, allow_nan=False)
|
|
43
|
+
except Exception:
|
|
44
|
+
return "<unserializable>"
|
|
45
|
+
return redacted
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _json_bytes(data: Mapping[str, object]) -> bytes:
|
|
49
|
+
return json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False, default=str).encode()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _truncate_utf8(value: str, byte_limit: int) -> str:
|
|
53
|
+
marker_bytes = len(_ELLIPSIS.encode())
|
|
54
|
+
if byte_limit <= marker_bytes:
|
|
55
|
+
return _ELLIPSIS
|
|
56
|
+
raw = value.encode()
|
|
57
|
+
if len(raw) <= byte_limit:
|
|
58
|
+
return value
|
|
59
|
+
return raw[: byte_limit - marker_bytes].decode(errors="ignore") + _ELLIPSIS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _string_slots(data: dict[str, object]) -> list[tuple[dict[str, object], str]]:
|
|
63
|
+
slots: list[tuple[dict[str, object], str]] = []
|
|
64
|
+
for key in ("message", "channel"):
|
|
65
|
+
if isinstance(data.get(key), str):
|
|
66
|
+
slots.append((data, key))
|
|
67
|
+
for container_name in ("context", "extra"):
|
|
68
|
+
container = data.get(container_name)
|
|
69
|
+
if isinstance(container, dict):
|
|
70
|
+
slots.extend(
|
|
71
|
+
(container, key)
|
|
72
|
+
for key, value in container.items()
|
|
73
|
+
if isinstance(key, str) and isinstance(value, str) and not (container_name == "extra" and key in _REQUIRED_EXTRA_KEYS)
|
|
74
|
+
)
|
|
75
|
+
return slots
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def trim_log(data: dict[str, object], max_bytes: int = DEFAULT_MAX_BYTES) -> str:
|
|
79
|
+
"""Serialize ``data`` as valid JSON no larger than ``max_bytes`` when possible."""
|
|
80
|
+
max_bytes = max(64, max_bytes)
|
|
81
|
+
encoded = _json_bytes(data)
|
|
82
|
+
previous = len(encoded) + 1
|
|
83
|
+
while len(encoded) > max_bytes and len(encoded) < previous:
|
|
84
|
+
previous = len(encoded)
|
|
85
|
+
slots = _string_slots(data)
|
|
86
|
+
if not slots:
|
|
87
|
+
break
|
|
88
|
+
excess = len(encoded) - max_bytes
|
|
89
|
+
reduction = max(1, excess // len(slots) + 1)
|
|
90
|
+
changed = False
|
|
91
|
+
for container, key in slots:
|
|
92
|
+
value = container[key]
|
|
93
|
+
if not isinstance(value, str):
|
|
94
|
+
continue
|
|
95
|
+
current = len(value.encode())
|
|
96
|
+
shortened = _truncate_utf8(value, max(len(_ELLIPSIS.encode()), current - reduction))
|
|
97
|
+
if shortened != value:
|
|
98
|
+
container[key] = shortened
|
|
99
|
+
changed = True
|
|
100
|
+
if not changed:
|
|
101
|
+
break
|
|
102
|
+
encoded = _json_bytes(data)
|
|
103
|
+
if len(encoded) > max_bytes:
|
|
104
|
+
context = data.get("context")
|
|
105
|
+
preserved: dict[str, object] = {}
|
|
106
|
+
if isinstance(context, dict):
|
|
107
|
+
for key in ("log_type", "request_id", "file", "exception", "exception_code"):
|
|
108
|
+
if key in context:
|
|
109
|
+
preserved[key] = context[key]
|
|
110
|
+
preserved["_truncated"] = True
|
|
111
|
+
data["context"] = preserved
|
|
112
|
+
extra = data.get("extra")
|
|
113
|
+
data["extra"] = {key: extra[key] for key in _REQUIRED_EXTRA_KEYS if key in extra} if isinstance(extra, dict) else {}
|
|
114
|
+
encoded = _json_bytes(data)
|
|
115
|
+
while len(encoded) > max_bytes:
|
|
116
|
+
slots = _string_slots(data)
|
|
117
|
+
if not slots:
|
|
118
|
+
break
|
|
119
|
+
longest_container, longest_key = max(slots, key=lambda slot: len(str(slot[0][slot[1]]).encode()))
|
|
120
|
+
value = longest_container[longest_key]
|
|
121
|
+
if not isinstance(value, str) or value == _ELLIPSIS:
|
|
122
|
+
break
|
|
123
|
+
longest_container[longest_key] = _truncate_utf8(value, max(len(_ELLIPSIS.encode()), len(value.encode()) // 2))
|
|
124
|
+
encoded = _json_bytes(data)
|
|
125
|
+
return encoded.decode()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ExtraFilter(logging.Filter):
|
|
129
|
+
"""Attach stable process metadata to every record."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, config: PylogConfig, argv: Sequence[str] | None = None) -> None:
|
|
132
|
+
super().__init__()
|
|
133
|
+
values = {
|
|
134
|
+
"app_name": config.extra.app_name or "",
|
|
135
|
+
"app_env": config.extra.app_env or "",
|
|
136
|
+
"app_ver": config.extra.app_ver or "",
|
|
137
|
+
"log_ver": config.extra.log_ver,
|
|
138
|
+
"tier": config.extra.tier or "",
|
|
139
|
+
"release_tag": config.extra.release_tag,
|
|
140
|
+
"release_time": config.extra.release_time,
|
|
141
|
+
}
|
|
142
|
+
self._extra = {key: value for key, value in values.items() if value is not None}
|
|
143
|
+
resolved_argv = tuple(sys.argv if argv is None else argv)
|
|
144
|
+
if resolved_argv:
|
|
145
|
+
self._extra["console_argv"] = " ".join(resolved_argv)
|
|
146
|
+
|
|
147
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
148
|
+
record.pylog_extra = dict(self._extra)
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class JsonFormatter(logging.Formatter):
|
|
153
|
+
"""Emit one newline-terminated JSON record conforming to the shared wire contract."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, config: PylogConfig | None = None, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
|
|
156
|
+
super().__init__()
|
|
157
|
+
self._config = config or PylogConfig.from_env()
|
|
158
|
+
self._manager = LogManager(self._config)
|
|
159
|
+
self._redactor = Redactor.from_config(self._config)
|
|
160
|
+
self._max_bytes = max_bytes
|
|
161
|
+
|
|
162
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
163
|
+
try:
|
|
164
|
+
raw_context = getattr(record, "context", None)
|
|
165
|
+
context = get_bound_context()
|
|
166
|
+
if isinstance(raw_context, Mapping):
|
|
167
|
+
context.update(raw_context)
|
|
168
|
+
if record.exc_info and isinstance(record.exc_info[1], BaseException) and "exception" not in context:
|
|
169
|
+
context["exception"] = record.exc_info[1]
|
|
170
|
+
message, enriched = self._manager.append_context(record.levelno, record.getMessage(), context)
|
|
171
|
+
message = self._redactor.redact_value(message)
|
|
172
|
+
level_name, level = _level(record.levelno)
|
|
173
|
+
raw_extra = getattr(record, "pylog_extra", {})
|
|
174
|
+
extra = raw_extra if isinstance(raw_extra, Mapping) else {}
|
|
175
|
+
data: dict[str, object] = {
|
|
176
|
+
"datetime": datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="microseconds"),
|
|
177
|
+
"level": level,
|
|
178
|
+
"level_name": level_name,
|
|
179
|
+
"channel": record.name,
|
|
180
|
+
"message": message,
|
|
181
|
+
"context": {key: _safe_json_value(value, self._redactor) for key, value in enriched.items() if value is not None},
|
|
182
|
+
"extra": {str(key): _safe_json_value(value, self._redactor) for key, value in extra.items() if value is not None},
|
|
183
|
+
}
|
|
184
|
+
return trim_log(data, self._max_bytes) + "\n"
|
|
185
|
+
except Exception:
|
|
186
|
+
return '{"level":400,"level_name":"error","channel":"pylog","message":"logging format error","context":{},"extra":{}}\n'
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class LineFormatter(JsonFormatter):
|
|
190
|
+
"""Readable single-line formatter with escaped user-controlled line breaks."""
|
|
191
|
+
|
|
192
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
193
|
+
data = json.loads(super().format(record))
|
|
194
|
+
message = str(data["message"]).replace("\r", "\\r").replace("\n", "\\n")
|
|
195
|
+
context = json.dumps(data["context"], ensure_ascii=False, separators=(",", ":"))
|
|
196
|
+
extra = json.dumps(data["extra"], ensure_ascii=False, separators=(",", ":"))
|
|
197
|
+
return f"[{data['datetime']}] {data['channel']}.{data['level_name']}: {message} {context} {extra}\n"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ConsoleFormatter(LineFormatter):
|
|
201
|
+
"""ANSI-coloured formatter for local interactive development."""
|
|
202
|
+
|
|
203
|
+
_COLORS: Final[Mapping[str, str]] = {
|
|
204
|
+
"debug": "1;36",
|
|
205
|
+
"info": "0;32",
|
|
206
|
+
"notice": "0;34",
|
|
207
|
+
"warning": "1;33",
|
|
208
|
+
"error": "0;31",
|
|
209
|
+
"critical": "0;31",
|
|
210
|
+
"alert": "0;35",
|
|
211
|
+
"emergency": "0;35",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
215
|
+
level_name, _ = _level(record.levelno)
|
|
216
|
+
line = super().format(record).rstrip("\n")
|
|
217
|
+
return f"\033[{self._COLORS[level_name]}m{line}\033[0m\n"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional logging handlers."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Redis list handler; importing this module does not import a Redis client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Pipeline(Protocol):
|
|
10
|
+
def lpush(self, key: str, value: str) -> Pipeline: ...
|
|
11
|
+
def ltrim(self, key: str, start: int, end: int) -> Pipeline: ...
|
|
12
|
+
def execute(self) -> object: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RedisClient(Protocol):
|
|
16
|
+
def lpush(self, key: str, value: str) -> object: ...
|
|
17
|
+
def pipeline(self, transaction: bool = True) -> Pipeline: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedisHandler(logging.Handler):
|
|
21
|
+
"""Best-effort ``LPUSH`` handler with an optional ``LTRIM`` cap."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, client: RedisClient, *, key: str = "logs", cap_size: int = 10_000, level: int = logging.INFO) -> None:
|
|
24
|
+
super().__init__(level)
|
|
25
|
+
if cap_size < 0:
|
|
26
|
+
raise ValueError("cap_size must be non-negative")
|
|
27
|
+
self._client = client
|
|
28
|
+
self._key = key
|
|
29
|
+
self._cap_size = cap_size
|
|
30
|
+
|
|
31
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
32
|
+
try:
|
|
33
|
+
payload = self.format(record)
|
|
34
|
+
if self._cap_size > 0:
|
|
35
|
+
pipeline = self._client.pipeline(transaction=True)
|
|
36
|
+
pipeline.lpush(self._key, payload)
|
|
37
|
+
pipeline.ltrim(self._key, 0, self._cap_size - 1)
|
|
38
|
+
pipeline.execute()
|
|
39
|
+
else:
|
|
40
|
+
self._client.lpush(self._key, payload)
|
|
41
|
+
except Exception:
|
|
42
|
+
self.handleError(record)
|
log_python/heartbeat.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Framework-neutral queue-worker heartbeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from log_python.const import LOGGER_COUNT, LOGGER_MONITORING, LOGGER_STATUS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Heartbeat:
|
|
14
|
+
def __init__(self, logger: logging.Logger, *, name: str = "default", interval: float = 120, clock: Callable[[], float] = time.monotonic) -> None:
|
|
15
|
+
self._logger = logger
|
|
16
|
+
self._name = name
|
|
17
|
+
self._interval = interval
|
|
18
|
+
self._clock = clock
|
|
19
|
+
self._last = 0.0
|
|
20
|
+
self._stop = threading.Event()
|
|
21
|
+
self._thread: threading.Thread | None = None
|
|
22
|
+
|
|
23
|
+
def tick(self, *, force: bool = False) -> bool:
|
|
24
|
+
now = self._clock()
|
|
25
|
+
if not force and (self._interval <= 0 or now - self._last <= self._interval):
|
|
26
|
+
return False
|
|
27
|
+
self._logger.info("heartbeat: %s", self._name, extra={"context": {LOGGER_MONITORING: "queue", LOGGER_COUNT: 0}})
|
|
28
|
+
self._last = now
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
def start(self) -> None:
|
|
32
|
+
if self._interval <= 0:
|
|
33
|
+
return
|
|
34
|
+
if self._thread is not None and self._thread.is_alive():
|
|
35
|
+
return
|
|
36
|
+
self._stop.clear()
|
|
37
|
+
self._thread = threading.Thread(target=self._run, name=f"pylog-heartbeat-{self._name}", daemon=True)
|
|
38
|
+
self._thread.start()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def running(self) -> bool:
|
|
42
|
+
return self._thread is not None and self._thread.is_alive()
|
|
43
|
+
|
|
44
|
+
def _run(self) -> None:
|
|
45
|
+
while not self._stop.wait(self._interval):
|
|
46
|
+
self.tick(force=True)
|
|
47
|
+
|
|
48
|
+
def stop(self, *, status: int = 0) -> None:
|
|
49
|
+
self._stop.set()
|
|
50
|
+
if self._thread is not None and self._thread is not threading.current_thread():
|
|
51
|
+
self._thread.join(timeout=max(1.0, self._interval))
|
|
52
|
+
self._logger.info("Queue stop signal", extra={"context": {LOGGER_MONITORING: "queue-stop", LOGGER_STATUS: status}})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional framework integrations."""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Optional SQLAlchemy slow-query instrumentation (dependency injected)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, Protocol, cast
|
|
12
|
+
|
|
13
|
+
from log_python.redactor import Redactor
|
|
14
|
+
|
|
15
|
+
_SQL_RE = re.compile(r"(UPDATE|DELETE\s+FROM|INSERT\s+INTO|FROM)\s+[`\"\[]?([\w.]+)[`\"\]]?", re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventApi(Protocol):
|
|
19
|
+
def listen(self, target: object, name: str, callback: Callable[..., None]) -> None: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classify_sql(statement: str) -> tuple[str, str]:
|
|
23
|
+
match = _SQL_RE.search(statement)
|
|
24
|
+
if not match:
|
|
25
|
+
return "", ""
|
|
26
|
+
sql_type = re.sub(r"\s+", " ", match.group(1).upper())
|
|
27
|
+
sql_type = {"FROM": "SELECT", "DELETE FROM": "DELETE", "INSERT INTO": "INSERT"}.get(sql_type, sql_type)
|
|
28
|
+
return sql_type, match.group(2)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_bindings(parameters: object, redactor: Redactor) -> str:
|
|
32
|
+
try:
|
|
33
|
+
if isinstance(parameters, (list, tuple)) and len(parameters) >= 20:
|
|
34
|
+
return ""
|
|
35
|
+
values = (
|
|
36
|
+
[f"`string({len(value)})`" if isinstance(value, str) and len(value) > 512 else value for value in parameters]
|
|
37
|
+
if isinstance(parameters, (list, tuple))
|
|
38
|
+
else parameters
|
|
39
|
+
)
|
|
40
|
+
return json.dumps(redactor.redact(values), default=str, ensure_ascii=False)
|
|
41
|
+
except Exception:
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_sqlalchemy_logging(
|
|
46
|
+
engine: object,
|
|
47
|
+
*,
|
|
48
|
+
logger: logging.Logger | None = None,
|
|
49
|
+
select_threshold_ms: int = 200,
|
|
50
|
+
all_threshold_ms: int = 500,
|
|
51
|
+
include_bindings: bool = False,
|
|
52
|
+
event_api: EventApi | None = None,
|
|
53
|
+
clock: Callable[[], float] = time.perf_counter,
|
|
54
|
+
) -> None:
|
|
55
|
+
if event_api is None:
|
|
56
|
+
event_api = cast(EventApi, importlib.import_module("sqlalchemy.event"))
|
|
57
|
+
target_logger = logger or logging.getLogger("sql")
|
|
58
|
+
redactor = Redactor()
|
|
59
|
+
|
|
60
|
+
def before(_conn: object, _cursor: object, _statement: str, _parameters: object, context: Any, _executemany: bool) -> None:
|
|
61
|
+
started = clock()
|
|
62
|
+
if isinstance(context, dict):
|
|
63
|
+
context["_pylog_started"] = started
|
|
64
|
+
else:
|
|
65
|
+
context._pylog_started = started
|
|
66
|
+
|
|
67
|
+
def after(_conn: object, _cursor: object, statement: str, parameters: object, context: Any, _executemany: bool) -> None:
|
|
68
|
+
if "telescope" in statement.lower():
|
|
69
|
+
return
|
|
70
|
+
started = context.get("_pylog_started") if isinstance(context, dict) else getattr(context, "_pylog_started", clock())
|
|
71
|
+
started_value = float(started) if isinstance(started, (int, float, str)) else clock()
|
|
72
|
+
milliseconds = round((clock() - started_value) * 1000)
|
|
73
|
+
sql_type, table = classify_sql(statement)
|
|
74
|
+
threshold = select_threshold_ms if sql_type == "SELECT" and select_threshold_ms else all_threshold_ms
|
|
75
|
+
if threshold and milliseconds < threshold:
|
|
76
|
+
return
|
|
77
|
+
fields = {
|
|
78
|
+
"db_table": table,
|
|
79
|
+
"db_time_ms": milliseconds,
|
|
80
|
+
"db_bindings": _safe_bindings(parameters, redactor) if include_bindings else "",
|
|
81
|
+
"tag": "sql",
|
|
82
|
+
"sql_type": sql_type,
|
|
83
|
+
}
|
|
84
|
+
target_logger.info(statement, extra={"context": fields})
|
|
85
|
+
|
|
86
|
+
event_api.listen(engine, "before_cursor_execute", before)
|
|
87
|
+
event_api.listen(engine, "after_cursor_execute", after)
|
log_python/logtype.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Log origin classification, ported from the reference `src/LogType.php` (spec
|
|
2
|
+
`docs/LoggingRules.md` §4.3.1, field `log_type`).
|
|
3
|
+
|
|
4
|
+
Classifies WHO initiated a log record:
|
|
5
|
+
|
|
6
|
+
- ``logger`` — an explicit call in code (the default).
|
|
7
|
+
- ``trigger`` — reserved for a runtime warning/error integration.
|
|
8
|
+
- ``exception`` — an uncaught-exception handler, or an explicit log call carrying
|
|
9
|
+
``context['exception']``.
|
|
10
|
+
- ``fatal`` — reserved for a supervisor/native-fatal integration.
|
|
11
|
+
|
|
12
|
+
`install_handlers()` provides opt-in, idempotent and chained `sys.excepthook`,
|
|
13
|
+
`threading.excepthook` and `warnings.showwarning` integration. Python has no PHP-style `error_get_last()` at
|
|
14
|
+
normal `atexit`, so this module does not fabricate a reliable `fatal` event from a
|
|
15
|
+
clean shutdown; native fatal signals remain the responsibility of the process
|
|
16
|
+
supervisor/faulthandler.
|
|
17
|
+
|
|
18
|
+
Python-idiom divergence from the reference (documented, not an oversight): PHP's
|
|
19
|
+
`LogType::$origin` is a single process-global static (correct for PHP-FPM's one-request-
|
|
20
|
+
per-process model). A bare global would leak across concurrent requests/tasks in a
|
|
21
|
+
threaded or async Python process, so this port uses a `contextvars.ContextVar` instead —
|
|
22
|
+
each thread/async-task context gets its own origin, isolated the way the spec's own
|
|
23
|
+
`request_id` stability period ("stable within one request") already implies concurrent
|
|
24
|
+
call-chains must not step on each other. The installed thread hook sets origin in the
|
|
25
|
+
failing thread before emitting and chaining the record.
|
|
26
|
+
|
|
27
|
+
Method names avoid `set`/`reset` as bare functions (`flake8-builtins`, enabled in this
|
|
28
|
+
project's `ruff.toml`, flags shadowing the `set` builtin); the reference's `LogType::set()`
|
|
29
|
+
/ `::reset()` become `LogType.set_origin()` / `LogType.reset_origin()` here.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import contextvars
|
|
35
|
+
import logging
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
import warnings
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from types import TracebackType
|
|
41
|
+
from typing import ClassVar, Final, TextIO
|
|
42
|
+
|
|
43
|
+
LOGGER: Final[str] = "logger"
|
|
44
|
+
TRIGGER: Final[str] = "trigger"
|
|
45
|
+
EXCEPTION: Final[str] = "exception"
|
|
46
|
+
FATAL: Final[str] = "fatal"
|
|
47
|
+
|
|
48
|
+
_ORIGIN: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar("pylog_log_type_origin", default=None)
|
|
49
|
+
_HANDLER_LOCK: Final[threading.Lock] = threading.Lock()
|
|
50
|
+
_INSTALLED = False
|
|
51
|
+
_PREVIOUS_SYS_HOOK = sys.excepthook
|
|
52
|
+
_PREVIOUS_THREAD_HOOK = threading.excepthook
|
|
53
|
+
_PREVIOUS_WARNING_HOOK = warnings.showwarning
|
|
54
|
+
_INSTALLED_SYS_HOOK: Callable[[type[BaseException], BaseException, TracebackType | None], None] | None = None
|
|
55
|
+
_INSTALLED_THREAD_HOOK: Callable[[threading.ExceptHookArgs], None] | None = None
|
|
56
|
+
_INSTALLED_WARNING_HOOK: Callable[[Warning | str, type[Warning], str, int, TextIO | None, str | None], None] | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LogType:
|
|
60
|
+
"""Namespace mirroring the reference `LogType` class (its four constants + the
|
|
61
|
+
process/context-scoped origin flag). Not instantiated — every member is a classmethod
|
|
62
|
+
or class-level constant, matching the PHP class's all-static shape.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
LOGGER: Final[str] = LOGGER
|
|
66
|
+
TRIGGER: Final[str] = TRIGGER
|
|
67
|
+
EXCEPTION: Final[str] = EXCEPTION
|
|
68
|
+
FATAL: Final[str] = FATAL
|
|
69
|
+
|
|
70
|
+
_origin: ClassVar[contextvars.ContextVar[str | None]] = _ORIGIN
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def current(cls) -> str:
|
|
74
|
+
"""The current origin, or `LOGGER` when nothing more specific has been set —
|
|
75
|
+
reference `LogType::current()`.
|
|
76
|
+
"""
|
|
77
|
+
return cls._origin.get() or cls.LOGGER
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def set_origin(cls, value: str | None) -> None:
|
|
81
|
+
"""Set the current context's origin. Reference `LogType::set()`."""
|
|
82
|
+
cls._origin.set(value)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def reset_origin(cls) -> None:
|
|
86
|
+
"""Clear the current context's origin back to the `LOGGER` default. Reference
|
|
87
|
+
`LogType::reset()`.
|
|
88
|
+
"""
|
|
89
|
+
cls._origin.set(None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def handlers_installed() -> bool:
|
|
93
|
+
"""Whether this module currently owns the process exception hooks."""
|
|
94
|
+
return _INSTALLED
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def install_handlers() -> None:
|
|
98
|
+
"""Install idempotent, chained uncaught-exception hooks."""
|
|
99
|
+
global _INSTALLED, _PREVIOUS_SYS_HOOK, _PREVIOUS_THREAD_HOOK, _PREVIOUS_WARNING_HOOK
|
|
100
|
+
global _INSTALLED_SYS_HOOK, _INSTALLED_THREAD_HOOK, _INSTALLED_WARNING_HOOK
|
|
101
|
+
with _HANDLER_LOCK:
|
|
102
|
+
if _INSTALLED:
|
|
103
|
+
return
|
|
104
|
+
_PREVIOUS_SYS_HOOK = sys.excepthook
|
|
105
|
+
_PREVIOUS_THREAD_HOOK = threading.excepthook
|
|
106
|
+
_PREVIOUS_WARNING_HOOK = warnings.showwarning
|
|
107
|
+
|
|
108
|
+
def sys_hook(exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None) -> None:
|
|
109
|
+
LogType.set_origin(LogType.EXCEPTION)
|
|
110
|
+
try:
|
|
111
|
+
logging.getLogger("log_python.uncaught").critical("uncaught exception", exc_info=(exc_type, exc, tb))
|
|
112
|
+
_PREVIOUS_SYS_HOOK(exc_type, exc, tb)
|
|
113
|
+
finally:
|
|
114
|
+
LogType.reset_origin()
|
|
115
|
+
|
|
116
|
+
def thread_hook(args: threading.ExceptHookArgs) -> None:
|
|
117
|
+
LogType.set_origin(LogType.EXCEPTION)
|
|
118
|
+
try:
|
|
119
|
+
if args.exc_value is not None:
|
|
120
|
+
logging.getLogger("log_python.uncaught").critical(
|
|
121
|
+
"uncaught thread exception",
|
|
122
|
+
exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
|
|
123
|
+
)
|
|
124
|
+
_PREVIOUS_THREAD_HOOK(args)
|
|
125
|
+
finally:
|
|
126
|
+
LogType.reset_origin()
|
|
127
|
+
|
|
128
|
+
def warning_hook(
|
|
129
|
+
message: Warning | str,
|
|
130
|
+
category: type[Warning],
|
|
131
|
+
filename: str,
|
|
132
|
+
lineno: int,
|
|
133
|
+
file: TextIO | None = None,
|
|
134
|
+
line: str | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
LogType.set_origin(LogType.TRIGGER)
|
|
137
|
+
try:
|
|
138
|
+
logging.getLogger("log_python.warning").warning(
|
|
139
|
+
str(message),
|
|
140
|
+
extra={"context": {"warning_category": category.__name__, "warning_file": filename, "warning_line": lineno}},
|
|
141
|
+
)
|
|
142
|
+
_PREVIOUS_WARNING_HOOK(message, category, filename, lineno, file, line)
|
|
143
|
+
finally:
|
|
144
|
+
LogType.reset_origin()
|
|
145
|
+
|
|
146
|
+
sys.excepthook = sys_hook
|
|
147
|
+
threading.excepthook = thread_hook
|
|
148
|
+
warnings.showwarning = warning_hook
|
|
149
|
+
_INSTALLED_SYS_HOOK = sys_hook
|
|
150
|
+
_INSTALLED_THREAD_HOOK = thread_hook
|
|
151
|
+
_INSTALLED_WARNING_HOOK = warning_hook
|
|
152
|
+
_INSTALLED = True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def uninstall_handlers() -> None:
|
|
156
|
+
"""Restore hooks captured by :func:`install_handlers`; primarily useful in tests."""
|
|
157
|
+
global _INSTALLED, _INSTALLED_SYS_HOOK, _INSTALLED_THREAD_HOOK, _INSTALLED_WARNING_HOOK
|
|
158
|
+
with _HANDLER_LOCK:
|
|
159
|
+
if not _INSTALLED:
|
|
160
|
+
return
|
|
161
|
+
if sys.excepthook is _INSTALLED_SYS_HOOK:
|
|
162
|
+
sys.excepthook = _PREVIOUS_SYS_HOOK
|
|
163
|
+
if threading.excepthook is _INSTALLED_THREAD_HOOK:
|
|
164
|
+
threading.excepthook = _PREVIOUS_THREAD_HOOK
|
|
165
|
+
if warnings.showwarning is _INSTALLED_WARNING_HOOK:
|
|
166
|
+
warnings.showwarning = _PREVIOUS_WARNING_HOOK
|
|
167
|
+
_INSTALLED_SYS_HOOK = None
|
|
168
|
+
_INSTALLED_THREAD_HOOK = None
|
|
169
|
+
_INSTALLED_WARNING_HOOK = None
|
|
170
|
+
_INSTALLED = False
|
|
171
|
+
LogType.reset_origin()
|