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/__init__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""xakki-log-python — structured logging library for Python.
|
|
2
|
+
|
|
3
|
+
GELF/Graylog-oriented, Python port of xakki/laralog (PHP/Laravel).
|
|
4
|
+
|
|
5
|
+
Includes the shared field/type contract, redaction, enrichment, trace capture,
|
|
6
|
+
wire formatters, explicit stdlib ``logging`` setup, scoped context and optional
|
|
7
|
+
Redis/SQLAlchemy integrations.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from log_python.config import ExtraConfig, PylogConfig, TraceConfig, TraceDepthConfig
|
|
14
|
+
from log_python.const import (
|
|
15
|
+
LOGGER_COUNT,
|
|
16
|
+
LOGGER_MCTIME,
|
|
17
|
+
LOGGER_MEMORY,
|
|
18
|
+
LOGGER_MEMORY_PEAK,
|
|
19
|
+
LOGGER_MONITORING,
|
|
20
|
+
LOGGER_SKIP_TRACE,
|
|
21
|
+
LOGGER_STATUS,
|
|
22
|
+
LOGGER_SUCCESS,
|
|
23
|
+
LOGGER_SUM,
|
|
24
|
+
LOGGER_TAG,
|
|
25
|
+
LOGGER_TIME,
|
|
26
|
+
LOGGER_VER,
|
|
27
|
+
)
|
|
28
|
+
from log_python.context import (
|
|
29
|
+
CONTEXT_FALSE_VALUES,
|
|
30
|
+
CONTEXT_MAX_STRING_BYTES,
|
|
31
|
+
CONTEXT_TYPE_BOOL,
|
|
32
|
+
CONTEXT_TYPE_FLOAT,
|
|
33
|
+
CONTEXT_TYPE_INT,
|
|
34
|
+
CONTEXT_TYPE_SKIP,
|
|
35
|
+
CONTEXT_TYPE_STRING,
|
|
36
|
+
ContextTypeCorrector,
|
|
37
|
+
bind,
|
|
38
|
+
bound_context,
|
|
39
|
+
clear,
|
|
40
|
+
get_bound_context,
|
|
41
|
+
remote_ip,
|
|
42
|
+
)
|
|
43
|
+
from log_python.formatters import ConsoleFormatter, ExtraFilter, JsonFormatter, LineFormatter, trim_log
|
|
44
|
+
from log_python.heartbeat import Heartbeat
|
|
45
|
+
from log_python.logtype import LogType, handlers_installed, install_handlers, uninstall_handlers
|
|
46
|
+
from log_python.manager import (
|
|
47
|
+
EXCEPTION_CHAIN_FIELD,
|
|
48
|
+
LogManager,
|
|
49
|
+
get_or_create_request_id,
|
|
50
|
+
reset_request_id,
|
|
51
|
+
set_request_id,
|
|
52
|
+
)
|
|
53
|
+
from log_python.redactor import BUILTIN_NEEDLES, MASK, Redactor
|
|
54
|
+
from log_python.trace import CapturedTrace, TraceCapture, exception_class_name, exception_origin, should_skip_trace
|
|
55
|
+
|
|
56
|
+
__version__ = LOGGER_VER
|
|
57
|
+
|
|
58
|
+
_SETUP_HANDLER: logging.Handler | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _PylogStreamHandler(logging.StreamHandler[TextIO]):
|
|
62
|
+
_pylog_handler = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def setup(
|
|
66
|
+
*,
|
|
67
|
+
config: PylogConfig | None = None,
|
|
68
|
+
level: int = logging.INFO,
|
|
69
|
+
stream: TextIO | None = None,
|
|
70
|
+
formatter: str | logging.Formatter = "json",
|
|
71
|
+
replace: bool = False,
|
|
72
|
+
) -> logging.Handler:
|
|
73
|
+
"""Configure one idempotent structured handler on the root logger."""
|
|
74
|
+
global _SETUP_HANDLER
|
|
75
|
+
resolved = config or PylogConfig.from_env()
|
|
76
|
+
root = logging.getLogger()
|
|
77
|
+
if _SETUP_HANDLER is not None and _SETUP_HANDLER in root.handlers and not replace:
|
|
78
|
+
return _SETUP_HANDLER
|
|
79
|
+
if _SETUP_HANDLER is not None:
|
|
80
|
+
root.removeHandler(_SETUP_HANDLER)
|
|
81
|
+
handler = _PylogStreamHandler(stream)
|
|
82
|
+
handler.terminator = ""
|
|
83
|
+
handler.setLevel(level)
|
|
84
|
+
handler.addFilter(ExtraFilter(resolved))
|
|
85
|
+
if isinstance(formatter, logging.Formatter):
|
|
86
|
+
selected = formatter
|
|
87
|
+
elif formatter == "json":
|
|
88
|
+
selected = JsonFormatter(resolved)
|
|
89
|
+
elif formatter == "line":
|
|
90
|
+
selected = LineFormatter(resolved)
|
|
91
|
+
elif formatter == "console":
|
|
92
|
+
selected = ConsoleFormatter(resolved)
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError(f"Unknown pylog formatter: {formatter!r}")
|
|
95
|
+
handler.setFormatter(selected)
|
|
96
|
+
root.addHandler(handler)
|
|
97
|
+
if root.level == logging.NOTSET or root.level > level:
|
|
98
|
+
root.setLevel(level)
|
|
99
|
+
_SETUP_HANDLER = handler
|
|
100
|
+
if resolved.capture_handlers:
|
|
101
|
+
install_handlers()
|
|
102
|
+
return handler
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"BUILTIN_NEEDLES",
|
|
107
|
+
"CONTEXT_FALSE_VALUES",
|
|
108
|
+
"CONTEXT_MAX_STRING_BYTES",
|
|
109
|
+
"CONTEXT_TYPE_BOOL",
|
|
110
|
+
"CONTEXT_TYPE_FLOAT",
|
|
111
|
+
"CONTEXT_TYPE_INT",
|
|
112
|
+
"CONTEXT_TYPE_SKIP",
|
|
113
|
+
"CONTEXT_TYPE_STRING",
|
|
114
|
+
"EXCEPTION_CHAIN_FIELD",
|
|
115
|
+
"LOGGER_COUNT",
|
|
116
|
+
"LOGGER_MCTIME",
|
|
117
|
+
"LOGGER_MEMORY",
|
|
118
|
+
"LOGGER_MEMORY_PEAK",
|
|
119
|
+
"LOGGER_MONITORING",
|
|
120
|
+
"LOGGER_SKIP_TRACE",
|
|
121
|
+
"LOGGER_STATUS",
|
|
122
|
+
"LOGGER_SUCCESS",
|
|
123
|
+
"LOGGER_SUM",
|
|
124
|
+
"LOGGER_TAG",
|
|
125
|
+
"LOGGER_TIME",
|
|
126
|
+
"LOGGER_VER",
|
|
127
|
+
"MASK",
|
|
128
|
+
"CapturedTrace",
|
|
129
|
+
"ConsoleFormatter",
|
|
130
|
+
"ContextTypeCorrector",
|
|
131
|
+
"ExtraConfig",
|
|
132
|
+
"ExtraFilter",
|
|
133
|
+
"Heartbeat",
|
|
134
|
+
"JsonFormatter",
|
|
135
|
+
"LineFormatter",
|
|
136
|
+
"LogManager",
|
|
137
|
+
"LogType",
|
|
138
|
+
"PylogConfig",
|
|
139
|
+
"Redactor",
|
|
140
|
+
"TraceCapture",
|
|
141
|
+
"TraceConfig",
|
|
142
|
+
"TraceDepthConfig",
|
|
143
|
+
"__version__",
|
|
144
|
+
"bind",
|
|
145
|
+
"bound_context",
|
|
146
|
+
"clear",
|
|
147
|
+
"exception_class_name",
|
|
148
|
+
"exception_origin",
|
|
149
|
+
"get_bound_context",
|
|
150
|
+
"get_or_create_request_id",
|
|
151
|
+
"handlers_installed",
|
|
152
|
+
"install_handlers",
|
|
153
|
+
"remote_ip",
|
|
154
|
+
"reset_request_id",
|
|
155
|
+
"set_request_id",
|
|
156
|
+
"setup",
|
|
157
|
+
"should_skip_trace",
|
|
158
|
+
"trim_log",
|
|
159
|
+
"uninstall_handlers",
|
|
160
|
+
]
|
log_python/config.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Runtime configuration ported from the reference `config/logger.php`.
|
|
2
|
+
|
|
3
|
+
The PHP original reads `env()` at config-definition time on purpose (its
|
|
4
|
+
comment: values are captured by `php artisan config:cache`, so they survive
|
|
5
|
+
into production, unlike an `env()` call from runtime code once the cache is
|
|
6
|
+
warm). The Python equivalent hazard is `os.environ` mutation after startup;
|
|
7
|
+
`PylogConfig.from_env()` mirrors the intent by taking a single snapshot at
|
|
8
|
+
construction time via an explicitly injectable mapping, so tests never need
|
|
9
|
+
to mutate the real process environment.
|
|
10
|
+
|
|
11
|
+
Every config object here is a frozen dataclass: build one at process start
|
|
12
|
+
and pass it around, never mutate it. `redact` and `context_type_rules` are
|
|
13
|
+
merge-on-top overrides consumed by later phases (Redactor, context type
|
|
14
|
+
coercion respectively) — they default to empty here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from types import MappingProxyType
|
|
23
|
+
|
|
24
|
+
from log_python.const import LOGGER_VER
|
|
25
|
+
|
|
26
|
+
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
27
|
+
_FALSE_VALUES = frozenset({"0", "false", "no", "off", ""})
|
|
28
|
+
|
|
29
|
+
# Python-side substitution for the PHP `excluded_partials` default
|
|
30
|
+
# (`Monolog`, `Illuminate/Log/`, `vendor/`) — see mapping.md.
|
|
31
|
+
_DEFAULT_EXCLUDED_PARTIALS: tuple[str, ...] = ("logging/", "site-packages/", "log_python/")
|
|
32
|
+
|
|
33
|
+
_EMPTY_CONTEXT_TYPE_RULES: Mapping[str, Mapping[str, str]] = MappingProxyType({})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_bool(env: Mapping[str, str], key: str, default: bool) -> bool:
|
|
37
|
+
"""Parse a boolean env var.
|
|
38
|
+
|
|
39
|
+
Accepts (case-insensitive) ``1``/``true``/``yes``/``on`` as True and
|
|
40
|
+
``0``/``false``/``no``/``off``/empty-string as False. Any other value
|
|
41
|
+
raises ``ValueError`` naming the offending variable.
|
|
42
|
+
"""
|
|
43
|
+
if key not in env:
|
|
44
|
+
return default
|
|
45
|
+
raw = env[key].strip().lower()
|
|
46
|
+
if raw in _TRUE_VALUES:
|
|
47
|
+
return True
|
|
48
|
+
if raw in _FALSE_VALUES:
|
|
49
|
+
return False
|
|
50
|
+
raise ValueError(f"Environment variable {key!r} is not a valid boolean: {env[key]!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_int(env: Mapping[str, str], key: str, default: int) -> int:
|
|
54
|
+
"""Parse an integer env var, raising ``ValueError`` naming the variable on non-integer input."""
|
|
55
|
+
if key not in env:
|
|
56
|
+
return default
|
|
57
|
+
raw = env[key].strip()
|
|
58
|
+
try:
|
|
59
|
+
return int(raw)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise ValueError(f"Environment variable {key!r} is not a valid integer: {env[key]!r}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_optional_str(env: Mapping[str, str], key: str) -> str | None:
|
|
65
|
+
"""Return the raw env var value, or None when unset (mirrors PHP `env()`)."""
|
|
66
|
+
return env.get(key)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ExtraConfig:
|
|
71
|
+
"""Stable-per-process fields dumped onto every record (spec §4.2 extra)."""
|
|
72
|
+
|
|
73
|
+
app_name: str | None = None
|
|
74
|
+
app_env: str | None = None
|
|
75
|
+
app_ver: str | None = None
|
|
76
|
+
log_ver: str = LOGGER_VER
|
|
77
|
+
tier: str | None = None
|
|
78
|
+
release_tag: str | None = None
|
|
79
|
+
release_time: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class TraceDepthConfig:
|
|
84
|
+
"""Trace depth (frames captured) per severity level."""
|
|
85
|
+
|
|
86
|
+
warning: int = 5
|
|
87
|
+
error: int = 10
|
|
88
|
+
critical: int = 20 # also alert / emergency
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class TraceConfig:
|
|
93
|
+
"""Stack trace capture settings (spec §3.7 / §4.6)."""
|
|
94
|
+
|
|
95
|
+
excluded_partials: tuple[str, ...] = _DEFAULT_EXCLUDED_PARTIALS
|
|
96
|
+
depth: TraceDepthConfig = field(default_factory=TraceDepthConfig)
|
|
97
|
+
arg_limit: int = 128
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class PylogConfig:
|
|
102
|
+
"""Top-level configuration, ported 1:1 (keys and defaults) from `config/logger.php`."""
|
|
103
|
+
|
|
104
|
+
message_limit: int = 3024
|
|
105
|
+
allow_memory: bool = False
|
|
106
|
+
extra: ExtraConfig = field(default_factory=ExtraConfig)
|
|
107
|
+
trace: TraceConfig = field(default_factory=TraceConfig)
|
|
108
|
+
# Extra redaction needles, merged on top of the Redactor built-ins (phase 2).
|
|
109
|
+
redact: tuple[str, ...] = ()
|
|
110
|
+
snake_case: bool = False
|
|
111
|
+
# Project overrides merged on top of the context type-coercion table (phase 3).
|
|
112
|
+
context_type_rules: Mapping[str, Mapping[str, str]] = field(default_factory=lambda: _EMPTY_CONTEXT_TYPE_RULES)
|
|
113
|
+
capture_handlers: bool = False
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> PylogConfig:
|
|
117
|
+
"""Build a config snapshot from environment variables, read exactly once.
|
|
118
|
+
|
|
119
|
+
`env` defaults to `os.environ`; pass an explicit mapping (e.g. in
|
|
120
|
+
tests) so the real process environment is never touched.
|
|
121
|
+
"""
|
|
122
|
+
resolved_env: Mapping[str, str] = os.environ if env is None else env
|
|
123
|
+
|
|
124
|
+
extra = ExtraConfig(
|
|
125
|
+
app_name=_parse_optional_str(resolved_env, "APP_NAME"),
|
|
126
|
+
app_env=_parse_optional_str(resolved_env, "APP_ENV"),
|
|
127
|
+
app_ver=_parse_optional_str(resolved_env, "APP_VERSION"),
|
|
128
|
+
# log_ver is a compile-time constant in the reference, not env()-backed.
|
|
129
|
+
log_ver=LOGGER_VER,
|
|
130
|
+
tier=_parse_optional_str(resolved_env, "LOG_TIER"),
|
|
131
|
+
release_tag=_parse_optional_str(resolved_env, "RELEASE_TAG"),
|
|
132
|
+
release_time=_parse_optional_str(resolved_env, "RELEASE_TIME"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return cls(
|
|
136
|
+
message_limit=_parse_int(resolved_env, "LOG_MESSAGE_LIMIT", 3024),
|
|
137
|
+
allow_memory=_parse_bool(resolved_env, "LOG_ALLOW_MEMORY", default=False),
|
|
138
|
+
extra=extra,
|
|
139
|
+
trace=TraceConfig(),
|
|
140
|
+
redact=(),
|
|
141
|
+
snake_case=_parse_bool(resolved_env, "LOG_SNAKE_CASE", default=False),
|
|
142
|
+
context_type_rules=_EMPTY_CONTEXT_TYPE_RULES,
|
|
143
|
+
capture_handlers=_parse_bool(resolved_env, "LOG_CAPTURE_HANDLERS", default=False),
|
|
144
|
+
)
|
log_python/const.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Field-name contract ported from the reference `src/const.php`.
|
|
2
|
+
|
|
3
|
+
Each ``LOGGER_*`` constant is a context/record field NAME (a dict key), not a
|
|
4
|
+
value — they must stay byte-for-byte identical to the PHP reference so a
|
|
5
|
+
mixed PHP/Python fleet emits the same field names on the wire.
|
|
6
|
+
|
|
7
|
+
``LOGGER_VER`` is the odd one out: it is the installed library version stamped
|
|
8
|
+
into the ``log_ver`` extra field (see ``config.ExtraConfig.log_ver``). It is
|
|
9
|
+
read from standard distribution metadata generated from ``pyproject.toml``;
|
|
10
|
+
``log_python.__version__`` aliases it, so releases have one authoritative version
|
|
11
|
+
declaration compatible with Hatchling, Poetry, PDM, Pipenv and pip.
|
|
12
|
+
|
|
13
|
+
PHP's ``DS`` (``DIRECTORY_SEPARATOR``) constant is PHP-only cruft and is
|
|
14
|
+
deliberately not ported.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _package_version() -> str:
|
|
22
|
+
try:
|
|
23
|
+
return version("xakki-log-python")
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
# Source-tree fallback for tools that import without installing the project.
|
|
26
|
+
return "0+unknown"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
LOGGER_VER: Final[str] = _package_version()
|
|
30
|
+
|
|
31
|
+
LOGGER_TIME: Final[str] = "time"
|
|
32
|
+
LOGGER_MCTIME: Final[str] = "mctime" # float seconds, e.g. 1.000001
|
|
33
|
+
LOGGER_MONITORING: Final[str] = "monitoring"
|
|
34
|
+
LOGGER_STATUS: Final[str] = "status"
|
|
35
|
+
LOGGER_COUNT: Final[str] = "count"
|
|
36
|
+
LOGGER_SUM: Final[str] = "sum"
|
|
37
|
+
LOGGER_MEMORY: Final[str] = "memory_usage"
|
|
38
|
+
LOGGER_MEMORY_PEAK: Final[str] = "memory_peak"
|
|
39
|
+
LOGGER_TAG: Final[str] = "tag"
|
|
40
|
+
LOGGER_SUCCESS: Final[str] = "success"
|
|
41
|
+
LOGGER_SKIP_TRACE: Final[str] = "skipTrace"
|