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.
@@ -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")
@@ -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",)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any