csrd-logging 0.1.0__tar.gz
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-0.1.0/.gitignore +217 -0
- csrd_logging-0.1.0/PKG-INFO +10 -0
- csrd_logging-0.1.0/README.md +154 -0
- csrd_logging-0.1.0/pyproject.toml +24 -0
- csrd_logging-0.1.0/src/csrd/logging/__init__.py +20 -0
- csrd_logging-0.1.0/src/csrd/logging/_filter.py +62 -0
- csrd_logging-0.1.0/src/csrd/logging/_logging.py +209 -0
- csrd_logging-0.1.0/src/csrd/logging/py.typed +0 -0
- csrd_logging-0.1.0/tests/test_logging.py +323 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
/env/
|
|
142
|
+
/venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
*.db
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Import linter cache
|
|
213
|
+
.import_linter_cache/
|
|
214
|
+
|
|
215
|
+
# IDE
|
|
216
|
+
.idea/
|
|
217
|
+
.idea/*
|
|
@@ -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,154 @@
|
|
|
1
|
+
# csrd.logging
|
|
2
|
+
|
|
3
|
+
Context-enriched logging for all application layers.
|
|
4
|
+
|
|
5
|
+
## Dependency tier
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Tier 1 csrd.models · csrd.lifespan · csrd.context
|
|
9
|
+
Tier 1.5 csrd.logging ← depends on csrd.context only
|
|
10
|
+
Tier 2 csrd.delegate · csrd.repository · csrd.service
|
|
11
|
+
Tier 3 csrd.versioning
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Components
|
|
15
|
+
|
|
16
|
+
### `ContextLogger`
|
|
17
|
+
|
|
18
|
+
A stdlib `Logger` wrapper that auto-enriches every message with request context (`hit_id`, `user_id`, path params) as `key=value` pairs.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from csrd.logging import ContextLogger
|
|
22
|
+
|
|
23
|
+
logger = ContextLogger(logging.getLogger(__name__))
|
|
24
|
+
logger.info("Order created", meta={"order_id": 42})
|
|
25
|
+
# → "Order created hit_id=abc-123 user_id=user1 order_id=42"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
All standard logging kwargs (`exc_info`, `extra`, `stacklevel`) pass through. The `meta` kwarg adds extra key=value pairs per call.
|
|
29
|
+
|
|
30
|
+
### `LoggingMixin`
|
|
31
|
+
|
|
32
|
+
A mixin providing `self.log` (a `ContextLogger`) for any class. Works at every layer.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from csrd.logging import LoggingMixin
|
|
36
|
+
|
|
37
|
+
class OrderService(BaseService, LoggingMixin):
|
|
38
|
+
async def place_order(self, cart):
|
|
39
|
+
self.log.info("Placing order", meta={"items": len(cart)})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### Auto-logging (opt-in)
|
|
43
|
+
|
|
44
|
+
Pass `auto_log=True` to automatically log entry and exceptions for every public method:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
class OrderService(BaseService, LoggingMixin, auto_log=True):
|
|
48
|
+
async def place_order(self, cart): # ← entry logged at INFO, exceptions at ERROR
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Exclude noisy methods:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
class MyService(BaseService, LoggingMixin, auto_log=True):
|
|
56
|
+
__log_exclude__ = {"health_check"}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both sync and async methods are handled. Private methods (`_name`) are always skipped.
|
|
60
|
+
|
|
61
|
+
### `RequestContextFilter`
|
|
62
|
+
|
|
63
|
+
A stdlib `logging.Filter` that injects context fields into log **records** (not messages). Use this when your logging backend (Splunk, ELK, Datadog) needs structured fields in a configurable format.
|
|
64
|
+
|
|
65
|
+
Fields added to each record:
|
|
66
|
+
|
|
67
|
+
| Field | Source | Default |
|
|
68
|
+
|-------|--------|---------|
|
|
69
|
+
| `hit_id` | Request trace ID | `"-"` |
|
|
70
|
+
| `user_id` | Authenticated user's `sub` claim | `"-"` |
|
|
71
|
+
| `app_id` | Application identifier header | `"-"` |
|
|
72
|
+
| `api_version` | Resolved API version | `"-"` |
|
|
73
|
+
|
|
74
|
+
## Production logging configuration
|
|
75
|
+
|
|
76
|
+
`ContextLogger` enriches log **messages** — it works out of the box with zero configuration. For production deployments you'll typically also want to configure stdlib logging with structured formatters, handlers, and filters.
|
|
77
|
+
|
|
78
|
+
### Approach 1: `ContextLogger` only (simplest)
|
|
79
|
+
|
|
80
|
+
No configuration needed. Messages include context automatically:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
2025-07-15 10:23:45 INFO Order created hit_id=abc-123 user_id=user1 order_id=42
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Good for development and simple deployments.
|
|
87
|
+
|
|
88
|
+
### Approach 2: YAML logging configuration (production)
|
|
89
|
+
|
|
90
|
+
Create a `config/logging.yml` in your application:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
version: 1
|
|
94
|
+
disable_existing_loggers: false
|
|
95
|
+
|
|
96
|
+
formatters:
|
|
97
|
+
structured:
|
|
98
|
+
style: "{"
|
|
99
|
+
format: "{asctime} hitId={hit_id} userId={user_id} module={name} func={funcName} level={levelname} {message}"
|
|
100
|
+
|
|
101
|
+
filters:
|
|
102
|
+
context:
|
|
103
|
+
"()": csrd.logging.RequestContextFilter
|
|
104
|
+
|
|
105
|
+
handlers:
|
|
106
|
+
console:
|
|
107
|
+
class: logging.StreamHandler
|
|
108
|
+
level: INFO
|
|
109
|
+
formatter: structured
|
|
110
|
+
stream: ext://sys.stdout
|
|
111
|
+
filters: [context]
|
|
112
|
+
|
|
113
|
+
loggers:
|
|
114
|
+
uvicorn:
|
|
115
|
+
handlers: [console]
|
|
116
|
+
level: INFO
|
|
117
|
+
propagate: false
|
|
118
|
+
uvicorn.access:
|
|
119
|
+
handlers: [console]
|
|
120
|
+
level: INFO
|
|
121
|
+
propagate: false
|
|
122
|
+
root:
|
|
123
|
+
level: INFO
|
|
124
|
+
handlers: [console]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Load it at startup:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import logging.config
|
|
131
|
+
import yaml
|
|
132
|
+
from pathlib import Path
|
|
133
|
+
|
|
134
|
+
with open(Path("config/logging.yml")) as f:
|
|
135
|
+
logging.config.dictConfig(yaml.safe_load(f))
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Approach 3: Both together (recommended for production)
|
|
139
|
+
|
|
140
|
+
Use `ContextLogger` / `LoggingMixin` in your code for dev-friendly messages, **and** attach `RequestContextFilter` to your production handlers for structured logging backends. The two are complementary — `ContextLogger` enriches the message text, `RequestContextFilter` adds record-level fields for formatters.
|
|
141
|
+
|
|
142
|
+
### When to use which
|
|
143
|
+
|
|
144
|
+
| Approach | Context in logs | Config required | Best for |
|
|
145
|
+
|----------|----------------|-----------------|----------|
|
|
146
|
+
| `ContextLogger` | In message text (`key=value`) | None | Dev, simple apps |
|
|
147
|
+
| `logging.yml` + `RequestContextFilter` | In log record fields | YAML file | Splunk, ELK, structured logging |
|
|
148
|
+
| Both together | Both | YAML file | Full production setup |
|
|
149
|
+
|
|
150
|
+
## Installation
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uv add csrd-logging
|
|
154
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-logging"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Context-enriched logging mixin with optional auto-instrumentation"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"csrd-context",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[tool.uv.sources]
|
|
12
|
+
csrd-context = { workspace = true }
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
16
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/logging"
|
|
17
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/csrd"]
|
|
@@ -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")
|
|
File without changes
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Tests for csrd.logging — ContextLogger and LoggingMixin."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from csrd.context._contextvars import reset_global_configuration
|
|
9
|
+
from csrd.context.platform import hit_id_context, user_info_context
|
|
10
|
+
from csrd.logging import ContextLogger, LoggingMixin, RequestContextFilter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _clean_context():
|
|
15
|
+
"""Reset context vars around each test."""
|
|
16
|
+
hit_token = hit_id_context.set("unknown")
|
|
17
|
+
user_token = user_info_context.set(None)
|
|
18
|
+
yield
|
|
19
|
+
hit_id_context.reset(hit_token)
|
|
20
|
+
user_info_context.reset(user_token)
|
|
21
|
+
reset_global_configuration()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── ContextLogger ────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestContextLogger:
|
|
28
|
+
def test_plain_message(self, caplog):
|
|
29
|
+
logger = ContextLogger(logging.getLogger("test.plain"))
|
|
30
|
+
with caplog.at_level(logging.INFO, logger="test.plain"):
|
|
31
|
+
logger.info("hello world")
|
|
32
|
+
assert "hello world" in caplog.text
|
|
33
|
+
|
|
34
|
+
def test_message_with_meta(self, caplog):
|
|
35
|
+
logger = ContextLogger(logging.getLogger("test.meta"))
|
|
36
|
+
with caplog.at_level(logging.INFO, logger="test.meta"):
|
|
37
|
+
logger.info("order created", meta={"order_id": 42})
|
|
38
|
+
assert "order created" in caplog.text
|
|
39
|
+
assert "order_id=42" in caplog.text
|
|
40
|
+
|
|
41
|
+
def test_enriched_with_hit_id(self, caplog):
|
|
42
|
+
hit_id_context.set("req-abc-123")
|
|
43
|
+
logger = ContextLogger(logging.getLogger("test.hit"))
|
|
44
|
+
with caplog.at_level(logging.INFO, logger="test.hit"):
|
|
45
|
+
logger.info("processing")
|
|
46
|
+
assert "hit_id=req-abc-123" in caplog.text
|
|
47
|
+
|
|
48
|
+
def test_enriched_with_user_id(self, caplog):
|
|
49
|
+
class FakeClaims:
|
|
50
|
+
sub = "user42"
|
|
51
|
+
|
|
52
|
+
user_info_context.set(FakeClaims())
|
|
53
|
+
logger = ContextLogger(logging.getLogger("test.user"))
|
|
54
|
+
with caplog.at_level(logging.INFO, logger="test.user"):
|
|
55
|
+
logger.info("action")
|
|
56
|
+
assert "user_id=user42" in caplog.text
|
|
57
|
+
|
|
58
|
+
def test_no_context_no_extras(self, caplog):
|
|
59
|
+
logger = ContextLogger(logging.getLogger("test.empty"))
|
|
60
|
+
with caplog.at_level(logging.INFO, logger="test.empty"):
|
|
61
|
+
logger.info("bare")
|
|
62
|
+
# Should just be the message, no key=value pairs
|
|
63
|
+
assert caplog.records[0].message == "bare"
|
|
64
|
+
|
|
65
|
+
def test_all_levels(self, caplog):
|
|
66
|
+
logger = ContextLogger(logging.getLogger("test.levels"))
|
|
67
|
+
with caplog.at_level(logging.DEBUG, logger="test.levels"):
|
|
68
|
+
logger.debug("d")
|
|
69
|
+
logger.info("i")
|
|
70
|
+
logger.warning("w")
|
|
71
|
+
logger.error("e")
|
|
72
|
+
level_records = [r for r in caplog.records if r.name == "test.levels"]
|
|
73
|
+
assert len(level_records) == 4
|
|
74
|
+
|
|
75
|
+
def test_exception_level(self, caplog):
|
|
76
|
+
logger = ContextLogger(logging.getLogger("test.exc"))
|
|
77
|
+
with caplog.at_level(logging.ERROR, logger="test.exc"):
|
|
78
|
+
try:
|
|
79
|
+
raise ValueError("boom")
|
|
80
|
+
except ValueError:
|
|
81
|
+
logger.exception("caught it")
|
|
82
|
+
assert "caught it" in caplog.text
|
|
83
|
+
|
|
84
|
+
def test_stdlib_logger_accessible(self):
|
|
85
|
+
stdlib = logging.getLogger("test.stdlib")
|
|
86
|
+
ctx = ContextLogger(stdlib)
|
|
87
|
+
assert ctx.stdlib_logger is stdlib
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── LoggingMixin ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestLoggingMixin:
|
|
94
|
+
def test_log_property_returns_context_logger(self):
|
|
95
|
+
class MyClass(LoggingMixin):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
obj = MyClass()
|
|
99
|
+
assert isinstance(obj.log, ContextLogger)
|
|
100
|
+
|
|
101
|
+
def test_log_property_cached(self):
|
|
102
|
+
class MyClass(LoggingMixin):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
obj = MyClass()
|
|
106
|
+
assert obj.log is obj.log
|
|
107
|
+
|
|
108
|
+
def test_logger_name_includes_class(self):
|
|
109
|
+
class OrderService(LoggingMixin):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
obj = OrderService()
|
|
113
|
+
assert "OrderService" in obj.log.stdlib_logger.name
|
|
114
|
+
|
|
115
|
+
def test_manual_logging(self, caplog):
|
|
116
|
+
class MyService(LoggingMixin):
|
|
117
|
+
def do_work(self):
|
|
118
|
+
self.log.info("doing work", meta={"item": 5})
|
|
119
|
+
|
|
120
|
+
svc = MyService()
|
|
121
|
+
with caplog.at_level(logging.INFO):
|
|
122
|
+
svc.do_work()
|
|
123
|
+
assert "doing work" in caplog.text
|
|
124
|
+
assert "item=5" in caplog.text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Auto-logging ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestAutoLogging:
|
|
131
|
+
def test_sync_method_logged(self, caplog):
|
|
132
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
133
|
+
def process(self):
|
|
134
|
+
return "done"
|
|
135
|
+
|
|
136
|
+
svc = Svc()
|
|
137
|
+
with caplog.at_level(logging.INFO):
|
|
138
|
+
result = svc.process()
|
|
139
|
+
assert result == "done"
|
|
140
|
+
assert "process" in caplog.text
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_async_method_logged(self, caplog):
|
|
144
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
145
|
+
async def fetch(self):
|
|
146
|
+
return 42
|
|
147
|
+
|
|
148
|
+
svc = Svc()
|
|
149
|
+
with caplog.at_level(logging.INFO):
|
|
150
|
+
result = await svc.fetch()
|
|
151
|
+
assert result == 42
|
|
152
|
+
assert "fetch" in caplog.text
|
|
153
|
+
|
|
154
|
+
def test_private_methods_skipped(self, caplog):
|
|
155
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
156
|
+
def _internal(self):
|
|
157
|
+
return "private"
|
|
158
|
+
|
|
159
|
+
def public(self):
|
|
160
|
+
return "public"
|
|
161
|
+
|
|
162
|
+
svc = Svc()
|
|
163
|
+
with caplog.at_level(logging.INFO):
|
|
164
|
+
svc._internal()
|
|
165
|
+
caplog.clear()
|
|
166
|
+
svc.public()
|
|
167
|
+
assert "public" in caplog.text
|
|
168
|
+
|
|
169
|
+
def test_excluded_methods_skipped(self, caplog):
|
|
170
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
171
|
+
__log_exclude__: ClassVar[set[str]] = {"health_check"}
|
|
172
|
+
|
|
173
|
+
def health_check(self):
|
|
174
|
+
return "ok"
|
|
175
|
+
|
|
176
|
+
def process(self):
|
|
177
|
+
return "done"
|
|
178
|
+
|
|
179
|
+
svc = Svc()
|
|
180
|
+
with caplog.at_level(logging.INFO):
|
|
181
|
+
svc.health_check()
|
|
182
|
+
assert "health_check" not in caplog.text
|
|
183
|
+
|
|
184
|
+
def test_exception_logged(self, caplog):
|
|
185
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
186
|
+
def fail(self):
|
|
187
|
+
raise ValueError("boom")
|
|
188
|
+
|
|
189
|
+
svc = Svc()
|
|
190
|
+
with caplog.at_level(logging.ERROR), pytest.raises(ValueError, match="boom"):
|
|
191
|
+
svc.fail()
|
|
192
|
+
assert "fail failed" in caplog.text
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
async def test_async_exception_logged(self, caplog):
|
|
196
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
197
|
+
async def fail(self):
|
|
198
|
+
raise RuntimeError("async boom")
|
|
199
|
+
|
|
200
|
+
svc = Svc()
|
|
201
|
+
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError, match="async boom"):
|
|
202
|
+
await svc.fail()
|
|
203
|
+
assert "fail failed" in caplog.text
|
|
204
|
+
|
|
205
|
+
def test_no_auto_log_by_default(self, caplog):
|
|
206
|
+
class Svc(LoggingMixin):
|
|
207
|
+
def process(self):
|
|
208
|
+
return "done"
|
|
209
|
+
|
|
210
|
+
svc = Svc()
|
|
211
|
+
with caplog.at_level(logging.INFO):
|
|
212
|
+
svc.process()
|
|
213
|
+
# No auto entry log
|
|
214
|
+
assert "process" not in caplog.text
|
|
215
|
+
|
|
216
|
+
def test_context_enrichment_in_auto_log(self, caplog):
|
|
217
|
+
hit_id_context.set("trace-xyz")
|
|
218
|
+
|
|
219
|
+
class Svc(LoggingMixin, auto_log=True):
|
|
220
|
+
def work(self):
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
svc = Svc()
|
|
224
|
+
with caplog.at_level(logging.INFO):
|
|
225
|
+
svc.work()
|
|
226
|
+
assert "hit_id=trace-xyz" in caplog.text
|
|
227
|
+
|
|
228
|
+
def test_excludes_inherited(self, caplog):
|
|
229
|
+
class Base(LoggingMixin, auto_log=True):
|
|
230
|
+
__log_exclude__: ClassVar[set[str]] = {"noisy"}
|
|
231
|
+
|
|
232
|
+
class Child(Base, auto_log=True):
|
|
233
|
+
__log_exclude__: ClassVar[set[str]] = {"also_noisy"}
|
|
234
|
+
|
|
235
|
+
def noisy(self):
|
|
236
|
+
return "n"
|
|
237
|
+
|
|
238
|
+
def also_noisy(self):
|
|
239
|
+
return "an"
|
|
240
|
+
|
|
241
|
+
def normal(self):
|
|
242
|
+
return "ok"
|
|
243
|
+
|
|
244
|
+
svc = Child()
|
|
245
|
+
with caplog.at_level(logging.INFO):
|
|
246
|
+
svc.noisy()
|
|
247
|
+
svc.also_noisy()
|
|
248
|
+
assert "noisy" not in caplog.text
|
|
249
|
+
assert "also_noisy" not in caplog.text
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── RequestContextFilter ─────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class TestRequestContextFilter:
|
|
256
|
+
def _make_handler_with_filter(
|
|
257
|
+
self, formatter_str: str
|
|
258
|
+
) -> tuple[logging.Logger, logging.Handler]:
|
|
259
|
+
"""Create a logger with a handler that uses RequestContextFilter."""
|
|
260
|
+
logger = logging.getLogger(f"test.filter.{id(self)}")
|
|
261
|
+
logger.setLevel(logging.DEBUG)
|
|
262
|
+
handler = logging.StreamHandler()
|
|
263
|
+
handler.addFilter(RequestContextFilter())
|
|
264
|
+
handler.setFormatter(logging.Formatter(formatter_str))
|
|
265
|
+
logger.addHandler(handler)
|
|
266
|
+
return logger, handler
|
|
267
|
+
|
|
268
|
+
def test_adds_hit_id_to_record(self, caplog):
|
|
269
|
+
hit_id_context.set("trace-999")
|
|
270
|
+
f = RequestContextFilter()
|
|
271
|
+
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
|
|
272
|
+
f.filter(record)
|
|
273
|
+
assert record.hit_id == "trace-999" # type: ignore[attr-defined]
|
|
274
|
+
|
|
275
|
+
def test_adds_user_id_to_record(self):
|
|
276
|
+
class FakeClaims:
|
|
277
|
+
sub = "alice"
|
|
278
|
+
|
|
279
|
+
user_info_context.set(FakeClaims())
|
|
280
|
+
f = RequestContextFilter()
|
|
281
|
+
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
|
|
282
|
+
f.filter(record)
|
|
283
|
+
assert record.user_id == "alice" # type: ignore[attr-defined]
|
|
284
|
+
|
|
285
|
+
def test_defaults_to_dash_when_no_context(self):
|
|
286
|
+
f = RequestContextFilter()
|
|
287
|
+
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
|
|
288
|
+
f.filter(record)
|
|
289
|
+
assert record.hit_id == "-" # type: ignore[attr-defined]
|
|
290
|
+
assert record.user_id == "-" # type: ignore[attr-defined]
|
|
291
|
+
assert record.app_id == "-" # type: ignore[attr-defined]
|
|
292
|
+
assert record.api_version == "-" # type: ignore[attr-defined]
|
|
293
|
+
|
|
294
|
+
def test_always_returns_true(self):
|
|
295
|
+
f = RequestContextFilter()
|
|
296
|
+
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
|
|
297
|
+
assert f.filter(record) is True
|
|
298
|
+
|
|
299
|
+
def test_works_with_formatter(self, caplog):
|
|
300
|
+
hit_id_context.set("req-abc")
|
|
301
|
+
|
|
302
|
+
class FakeClaims:
|
|
303
|
+
sub = "bob"
|
|
304
|
+
|
|
305
|
+
user_info_context.set(FakeClaims())
|
|
306
|
+
|
|
307
|
+
logger = logging.getLogger("test.filter.fmt")
|
|
308
|
+
logger.handlers.clear()
|
|
309
|
+
logger.setLevel(logging.INFO)
|
|
310
|
+
|
|
311
|
+
handler = logging.StreamHandler()
|
|
312
|
+
handler.addFilter(RequestContextFilter())
|
|
313
|
+
fmt = logging.Formatter("%(message)s hit_id=%(hit_id)s user_id=%(user_id)s")
|
|
314
|
+
handler.setFormatter(fmt)
|
|
315
|
+
logger.addHandler(handler)
|
|
316
|
+
|
|
317
|
+
with caplog.at_level(logging.INFO, logger="test.filter.fmt"):
|
|
318
|
+
logger.info("hello")
|
|
319
|
+
|
|
320
|
+
# The caplog records should have the filter-injected attributes
|
|
321
|
+
record = next(r for r in caplog.records if r.name == "test.filter.fmt")
|
|
322
|
+
assert record.hit_id == "req-abc" # type: ignore[attr-defined]
|
|
323
|
+
assert record.user_id == "bob" # type: ignore[attr-defined]
|