csrd-logging 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csrd/logging/__init__.py +20 -0
- csrd/logging/_filter.py +62 -0
- csrd/logging/_logging.py +209 -0
- csrd/logging/py.typed +0 -0
- csrd_logging-0.1.0.dist-info/METADATA +10 -0
- csrd_logging-0.1.0.dist-info/RECORD +7 -0
- csrd_logging-0.1.0.dist-info/WHEEL +4 -0
csrd/logging/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Context-enriched logging for all application layers.
|
|
2
|
+
|
|
3
|
+
Provides :class:`ContextLogger` (a stdlib ``Logger`` wrapper that
|
|
4
|
+
auto-enriches messages with request context), :class:`LoggingMixin`
|
|
5
|
+
(a mixin with optional auto-instrumentation of public methods), and
|
|
6
|
+
:class:`RequestContextFilter` (a stdlib ``logging.Filter`` for
|
|
7
|
+
production formatters).
|
|
8
|
+
|
|
9
|
+
Usable at any tier — services, delegates, repositories::
|
|
10
|
+
|
|
11
|
+
from csrd.logging import LoggingMixin
|
|
12
|
+
|
|
13
|
+
class OrderService(BaseService, LoggingMixin, auto_log=True):
|
|
14
|
+
...
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._filter import RequestContextFilter
|
|
18
|
+
from ._logging import ContextLogger, LoggingMixin
|
|
19
|
+
|
|
20
|
+
__all__ = ("ContextLogger", "LoggingMixin", "RequestContextFilter")
|
csrd/logging/_filter.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Stdlib ``logging.Filter`` that injects request context into log records.
|
|
2
|
+
|
|
3
|
+
Attach this filter to any handler (typically via ``logging.yml``) to make
|
|
4
|
+
context fields available to formatters::
|
|
5
|
+
|
|
6
|
+
filters:
|
|
7
|
+
context:
|
|
8
|
+
"()": csrd.logging.RequestContextFilter
|
|
9
|
+
|
|
10
|
+
formatters:
|
|
11
|
+
structured:
|
|
12
|
+
style: "{"
|
|
13
|
+
format: "{asctime} hitId={hit_id} userId={user_id} level={levelname} {message}"
|
|
14
|
+
|
|
15
|
+
Fields added to each record:
|
|
16
|
+
|
|
17
|
+
* ``hit_id`` — request trace ID from ``csrd.context``
|
|
18
|
+
* ``user_id`` — authenticated user's ``sub`` claim
|
|
19
|
+
* ``app_id`` — application identifier header
|
|
20
|
+
* ``api_version`` — resolved API version for the request
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
from csrd.context import get_api_version, get_app_id
|
|
28
|
+
from csrd.context.platform import hit_id_context, user_info_context
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RequestContextFilter(logging.Filter):
|
|
32
|
+
"""Injects request context fields into every log record.
|
|
33
|
+
|
|
34
|
+
Unknown/unavailable fields default to ``"-"`` so formatters never
|
|
35
|
+
raise ``KeyError``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_FALLBACK = "-"
|
|
39
|
+
|
|
40
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
41
|
+
record.hit_id = self._get_hit_id() # type: ignore[attr-defined]
|
|
42
|
+
record.user_id = self._get_user_id() # type: ignore[attr-defined]
|
|
43
|
+
record.app_id = get_app_id() or self._FALLBACK # type: ignore[attr-defined]
|
|
44
|
+
record.api_version = get_api_version() or self._FALLBACK # type: ignore[attr-defined]
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _get_hit_id() -> str:
|
|
49
|
+
val = hit_id_context.get()
|
|
50
|
+
return val if val and val != "unknown" else "-"
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _get_user_id() -> str:
|
|
54
|
+
user = user_info_context.get()
|
|
55
|
+
if user is not None:
|
|
56
|
+
sub = getattr(user, "sub", None)
|
|
57
|
+
if sub:
|
|
58
|
+
return str(sub)
|
|
59
|
+
return "-"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ("RequestContextFilter",)
|
csrd/logging/_logging.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Context-enriched logging mixin and logger facade.
|
|
2
|
+
|
|
3
|
+
``LoggingMixin`` and ``ContextLogger`` provide structured, context-aware
|
|
4
|
+
logging for any layer of the application (services, delegates, repositories).
|
|
5
|
+
|
|
6
|
+
``ContextLogger`` wraps a stdlib :class:`logging.Logger` and automatically
|
|
7
|
+
enriches every log message with available request context (``hit_id``,
|
|
8
|
+
``user_id``, path params) formatted as ``key=value`` pairs.
|
|
9
|
+
|
|
10
|
+
``LoggingMixin`` is a mixin class that provides a ``self.log`` property
|
|
11
|
+
returning a ``ContextLogger``. Subclasses can opt into **auto-logging**
|
|
12
|
+
of all public methods via ``__init_subclass__``::
|
|
13
|
+
|
|
14
|
+
class OrderService(BaseService, LoggingMixin, auto_log=True):
|
|
15
|
+
async def place_order(self, cart: Cart) -> Order:
|
|
16
|
+
# entry + exception logging happens automatically
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
class QuietService(BaseService, LoggingMixin):
|
|
20
|
+
def do_work(self):
|
|
21
|
+
self.log.info("manual log", meta={"item": 42})
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import functools
|
|
28
|
+
import inspect
|
|
29
|
+
import logging
|
|
30
|
+
from typing import Any, ClassVar
|
|
31
|
+
|
|
32
|
+
from csrd.context import get_path_params
|
|
33
|
+
from csrd.context.platform import hit_id_context, user_info_context
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _collect_context() -> dict[str, Any]:
|
|
37
|
+
"""Gather available request context as a flat dict."""
|
|
38
|
+
ctx: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
hit_id = hit_id_context.get()
|
|
41
|
+
if hit_id and hit_id != "unknown":
|
|
42
|
+
ctx["hit_id"] = hit_id
|
|
43
|
+
|
|
44
|
+
user = user_info_context.get()
|
|
45
|
+
if user is not None:
|
|
46
|
+
sub = getattr(user, "sub", None)
|
|
47
|
+
if sub:
|
|
48
|
+
ctx["user_id"] = sub
|
|
49
|
+
|
|
50
|
+
path_params = get_path_params()
|
|
51
|
+
if path_params:
|
|
52
|
+
ctx.update(path_params)
|
|
53
|
+
|
|
54
|
+
return ctx
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_message(message: str, meta: dict[str, Any] | None = None) -> str:
|
|
58
|
+
"""Format a message with context and optional meta as key=value pairs."""
|
|
59
|
+
parts: dict[str, Any] = {}
|
|
60
|
+
parts.update(_collect_context())
|
|
61
|
+
if meta:
|
|
62
|
+
parts.update(meta)
|
|
63
|
+
|
|
64
|
+
if not parts:
|
|
65
|
+
return message
|
|
66
|
+
|
|
67
|
+
kv = " ".join(f"{k}={v}" for k, v in parts.items())
|
|
68
|
+
return f"{message} {kv}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ContextLogger:
|
|
72
|
+
"""Wraps a stdlib ``Logger`` — auto-enriches messages with request context.
|
|
73
|
+
|
|
74
|
+
Usage::
|
|
75
|
+
|
|
76
|
+
logger = ContextLogger(logging.getLogger(__name__))
|
|
77
|
+
logger.info("Order created", meta={"order_id": 42})
|
|
78
|
+
# → "Order created hit_id=abc-123 user_id=user1 order_id=42"
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
__slots__ = ("_logger",)
|
|
82
|
+
|
|
83
|
+
def __init__(self, logger: logging.Logger) -> None:
|
|
84
|
+
self._logger = logger
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def stdlib_logger(self) -> logging.Logger:
|
|
88
|
+
"""Access the underlying stdlib logger directly."""
|
|
89
|
+
return self._logger
|
|
90
|
+
|
|
91
|
+
def info(
|
|
92
|
+
self, message: str, *args: Any, meta: dict[str, Any] | None = None, **kwargs: Any
|
|
93
|
+
) -> None:
|
|
94
|
+
kwargs.setdefault("stacklevel", 2)
|
|
95
|
+
self._logger.info(_format_message(message, meta), *args, **kwargs)
|
|
96
|
+
|
|
97
|
+
def error(
|
|
98
|
+
self, message: str, *args: Any, meta: dict[str, Any] | None = None, **kwargs: Any
|
|
99
|
+
) -> None:
|
|
100
|
+
kwargs.setdefault("stacklevel", 2)
|
|
101
|
+
self._logger.error(_format_message(message, meta), *args, **kwargs)
|
|
102
|
+
|
|
103
|
+
def warning(
|
|
104
|
+
self, message: str, *args: Any, meta: dict[str, Any] | None = None, **kwargs: Any
|
|
105
|
+
) -> None:
|
|
106
|
+
kwargs.setdefault("stacklevel", 2)
|
|
107
|
+
self._logger.warning(_format_message(message, meta), *args, **kwargs)
|
|
108
|
+
|
|
109
|
+
def debug(
|
|
110
|
+
self, message: str, *args: Any, meta: dict[str, Any] | None = None, **kwargs: Any
|
|
111
|
+
) -> None:
|
|
112
|
+
kwargs.setdefault("stacklevel", 2)
|
|
113
|
+
self._logger.debug(_format_message(message, meta), *args, **kwargs)
|
|
114
|
+
|
|
115
|
+
def exception(
|
|
116
|
+
self, message: str, *args: Any, meta: dict[str, Any] | None = None, **kwargs: Any
|
|
117
|
+
) -> None:
|
|
118
|
+
kwargs.setdefault("stacklevel", 2)
|
|
119
|
+
self._logger.exception(_format_message(message, meta), *args, **kwargs)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LoggingMixin:
|
|
123
|
+
"""Mixin that provides a context-enriched :class:`ContextLogger`.
|
|
124
|
+
|
|
125
|
+
Compose with any base class::
|
|
126
|
+
|
|
127
|
+
class MyService(BaseService, LoggingMixin):
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
class MyDelegate(BaseDelegate, LoggingMixin):
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
**Auto-logging** (opt-in): decorate all public methods with entry/exception
|
|
134
|
+
logging automatically::
|
|
135
|
+
|
|
136
|
+
class MyService(BaseService, LoggingMixin, auto_log=True):
|
|
137
|
+
__log_exclude__ = {"health_check"} # skip noisy methods
|
|
138
|
+
...
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
__log_exclude__: ClassVar[set[str]] = set()
|
|
142
|
+
|
|
143
|
+
_context_logger: ContextLogger
|
|
144
|
+
|
|
145
|
+
def __init_subclass__(cls, auto_log: bool = False, **kwargs: Any) -> None:
|
|
146
|
+
super().__init_subclass__(**kwargs)
|
|
147
|
+
if not auto_log:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Collect excludes from the full MRO
|
|
151
|
+
excludes: set[str] = set()
|
|
152
|
+
for klass in cls.__mro__:
|
|
153
|
+
excludes |= getattr(klass, "__log_exclude__", set())
|
|
154
|
+
|
|
155
|
+
logger = logging.getLogger(f"{cls.__module__}.{cls.__qualname__}")
|
|
156
|
+
|
|
157
|
+
for attr_name, attr_value in list(cls.__dict__.items()):
|
|
158
|
+
if attr_name.startswith("_") or attr_name in excludes or not callable(attr_value):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if asyncio.iscoroutinefunction(attr_value):
|
|
162
|
+
setattr(cls, attr_name, _wrap_async(logger, attr_name, attr_value))
|
|
163
|
+
elif inspect.isfunction(attr_value):
|
|
164
|
+
setattr(cls, attr_name, _wrap_sync(logger, attr_name, attr_value))
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def log(self) -> ContextLogger:
|
|
168
|
+
"""Context-enriched logger for this instance."""
|
|
169
|
+
try:
|
|
170
|
+
return self._context_logger
|
|
171
|
+
except AttributeError:
|
|
172
|
+
name = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
|
|
173
|
+
self._context_logger = ContextLogger(logging.getLogger(name))
|
|
174
|
+
return self._context_logger
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _wrap_async(logger: logging.Logger, method_name: str, fn: Any) -> Any:
|
|
178
|
+
@functools.wraps(fn)
|
|
179
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
180
|
+
logger.info(_format_message(method_name), stacklevel=2)
|
|
181
|
+
try:
|
|
182
|
+
return await fn(*args, **kwargs)
|
|
183
|
+
except Exception:
|
|
184
|
+
logger.exception(
|
|
185
|
+
_format_message(f"{method_name} failed"),
|
|
186
|
+
stacklevel=2,
|
|
187
|
+
)
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
return wrapper
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _wrap_sync(logger: logging.Logger, method_name: str, fn: Any) -> Any:
|
|
194
|
+
@functools.wraps(fn)
|
|
195
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
196
|
+
logger.info(_format_message(method_name), stacklevel=2)
|
|
197
|
+
try:
|
|
198
|
+
return fn(*args, **kwargs)
|
|
199
|
+
except Exception:
|
|
200
|
+
logger.exception(
|
|
201
|
+
_format_message(f"{method_name} failed"),
|
|
202
|
+
stacklevel=2,
|
|
203
|
+
)
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
return wrapper
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = ("ContextLogger", "LoggingMixin")
|
csrd/logging/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-logging
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Context-enriched logging mixin with optional auto-instrumentation
|
|
5
|
+
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
|
+
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/logging
|
|
7
|
+
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: csrd-context
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
csrd/logging/__init__.py,sha256=n66-lxe4Y5xkYHq17ZWTlqw4RAYH3qcIVZ01VgfMv4U,694
|
|
2
|
+
csrd/logging/_filter.py,sha256=tNQIlgW1pM1DorkldoutLuo2CRNK8V_OsA2NiMekTdk,1884
|
|
3
|
+
csrd/logging/_logging.py,sha256=0ORuq9D5-u5gie24NEemOfXAzcshAPfXdh56P-EQP2A,6756
|
|
4
|
+
csrd/logging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
csrd_logging-0.1.0.dist-info/METADATA,sha256=ZaIqKoLmZ0PKVmVWrPD3WgRgeffEKBLX2GRrJakOegg,452
|
|
6
|
+
csrd_logging-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
csrd_logging-0.1.0.dist-info/RECORD,,
|