mh_structlog 0.0.40__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.
- mh_structlog-0.0.40/PKG-INFO +187 -0
- mh_structlog-0.0.40/README.md +169 -0
- mh_structlog-0.0.40/pyproject.toml +316 -0
- mh_structlog-0.0.40/src/mh_structlog/__init__.py +37 -0
- mh_structlog-0.0.40/src/mh_structlog/config.py +272 -0
- mh_structlog-0.0.40/src/mh_structlog/django.py +80 -0
- mh_structlog-0.0.40/src/mh_structlog/processors.py +106 -0
- mh_structlog-0.0.40/src/mh_structlog/utils.py +22 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mh_structlog
|
|
3
|
+
Version: 0.0.40
|
|
4
|
+
Summary: Some Structlog configuration and wrappers to easily use structlog.
|
|
5
|
+
Author: Mathieu Hinderyckx
|
|
6
|
+
Author-email: Mathieu Hinderyckx <mathieu.hinderyckx@gmail.com>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Dist: orjson>=3.11.5
|
|
11
|
+
Requires-Dist: rich>=14.0.0
|
|
12
|
+
Requires-Dist: structlog>=25.5.0
|
|
13
|
+
Requires-Dist: structlog-sentry>=2.2.1
|
|
14
|
+
Requires-Dist: django>=5.0 ; extra == 'django'
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Provides-Extra: django
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# MH-Structlog
|
|
20
|
+
|
|
21
|
+
This package is used to setup the python logging system in combination with structlog. It configures both structlog and the standard library logging module, so your code can either use a structlog logger (which is recommended) or keep working with the standard logging library. This way all third-party packages that are producing logs (which use the stdlib logging module) will follow your logging setup and you will always output structured logging.
|
|
22
|
+
|
|
23
|
+
It is a fairly opinionated setup but has some configuration options to influence the behaviour. The two output log formats are either pretty-printing (for interactive views) or json. It includes optional reporting to Sentry, and can also log to a file.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
This library should behave mostly as a drop-in import instead of the logging library import.
|
|
28
|
+
|
|
29
|
+
So instead of
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import logging
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
logger.info('hey')
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
you can do
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import mh_structlog as logging
|
|
43
|
+
logging.setup() # necessary once at program startup, see readme further below
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
logger.info('hey')
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
One big advantage of using the structlog logger over de stdlib logging one, is that you can pass arbitrary keyword arguments to our loggers when producing logs. E.g.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import mh_structlog as logging
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
logger.info('some message', hey='ho', a_list=[1,2,3])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
These extra key-value pairs will be included in the produced logs; either pretty-printed to the console or as data in the json entries.
|
|
61
|
+
|
|
62
|
+
## Configuration via `setup()`
|
|
63
|
+
|
|
64
|
+
To configure your logging, call the `setup` function, which should be called once as early as possible in your program execution. This function configures all loggers.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import mh_structlog as logging
|
|
68
|
+
|
|
69
|
+
logging.setup()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This will work out of the box with sane defaults: it logs to stdout in a pretty colored output when running in an interactive terminal, else it defaults to producing json output. See the next section for information on the arguments to this method.
|
|
73
|
+
|
|
74
|
+
### Configuration options
|
|
75
|
+
|
|
76
|
+
For a setup which logs everything to the console in a pretty (colored) output, simply do:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from mh_structlog import *
|
|
80
|
+
|
|
81
|
+
setup(
|
|
82
|
+
log_format='console',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
getLogger().info('hey')
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
To log as json:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from mh_structlog import *
|
|
92
|
+
|
|
93
|
+
setup(
|
|
94
|
+
log_format='json',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
getLogger().info('hey')
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
To filter everything out up to a certain level:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from mh_structlog import *
|
|
104
|
+
|
|
105
|
+
setup(
|
|
106
|
+
log_format='console',
|
|
107
|
+
global_filter_level=WARNING,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
getLogger().info('hey') # this does not get printed
|
|
111
|
+
getLogger().error('hey') # this does get printed
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
To write logs to a file additionally (next to stdout):
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from mh_structlog import *
|
|
118
|
+
|
|
119
|
+
setup(
|
|
120
|
+
log_format='console',
|
|
121
|
+
log_file='myfile.log',
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
getLogger().info('hey')
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
To silence specific named loggers specifically (instead of setting the log level globally, it can be done per named logger):
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from mh_structlog import *
|
|
131
|
+
|
|
132
|
+
setup(
|
|
133
|
+
log_format='console',
|
|
134
|
+
logging_configs=[
|
|
135
|
+
filter_named_logger('some_named_logger', WARNING),
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
getLogger('some_named_logger').info('hey') # does not get logged
|
|
140
|
+
getLogger('some_named_logger').warning('hey') # does get logged
|
|
141
|
+
|
|
142
|
+
getLogger('some_other_named_logger').info('hey') # does get logged
|
|
143
|
+
getLogger('some_other_named_logger').warning('hey') # does get logged
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
To include the source information about where a log was produced:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from mh_structlog import *
|
|
150
|
+
|
|
151
|
+
setup(
|
|
152
|
+
include_source_location=True
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
getLogger().info('hey')
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
To choose how many frames you want to include in stacktraces on logging exceptions:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from mh_structlog import *
|
|
162
|
+
|
|
163
|
+
setup(
|
|
164
|
+
log_format='json',
|
|
165
|
+
max_frames=3,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
5 / 0
|
|
170
|
+
except Exception as e:
|
|
171
|
+
getLogger().exception(e)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
To enable Sentry integration, pass a dict with a config according to the arguments which [structlog-sentry](https://github.com/kiwicom/structlog-sentry?tab=readme-ov-file#usage) allows to the setup function:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from mh_structlog import *
|
|
178
|
+
import sentry_sdk
|
|
179
|
+
|
|
180
|
+
config = {'dsn': '1234'}
|
|
181
|
+
sentry_sdk.init(dsn=config['dsn'])
|
|
182
|
+
|
|
183
|
+
setup(
|
|
184
|
+
sentry_config={'event_level': WARNING} # pass everything starting from WARNING level to Sentry
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
```
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# MH-Structlog
|
|
2
|
+
|
|
3
|
+
This package is used to setup the python logging system in combination with structlog. It configures both structlog and the standard library logging module, so your code can either use a structlog logger (which is recommended) or keep working with the standard logging library. This way all third-party packages that are producing logs (which use the stdlib logging module) will follow your logging setup and you will always output structured logging.
|
|
4
|
+
|
|
5
|
+
It is a fairly opinionated setup but has some configuration options to influence the behaviour. The two output log formats are either pretty-printing (for interactive views) or json. It includes optional reporting to Sentry, and can also log to a file.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
This library should behave mostly as a drop-in import instead of the logging library import.
|
|
10
|
+
|
|
11
|
+
So instead of
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
logger.info('hey')
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
you can do
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import mh_structlog as logging
|
|
25
|
+
logging.setup() # necessary once at program startup, see readme further below
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
logger.info('hey')
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
One big advantage of using the structlog logger over de stdlib logging one, is that you can pass arbitrary keyword arguments to our loggers when producing logs. E.g.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import mh_structlog as logging
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
logger.info('some message', hey='ho', a_list=[1,2,3])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
These extra key-value pairs will be included in the produced logs; either pretty-printed to the console or as data in the json entries.
|
|
43
|
+
|
|
44
|
+
## Configuration via `setup()`
|
|
45
|
+
|
|
46
|
+
To configure your logging, call the `setup` function, which should be called once as early as possible in your program execution. This function configures all loggers.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import mh_structlog as logging
|
|
50
|
+
|
|
51
|
+
logging.setup()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This will work out of the box with sane defaults: it logs to stdout in a pretty colored output when running in an interactive terminal, else it defaults to producing json output. See the next section for information on the arguments to this method.
|
|
55
|
+
|
|
56
|
+
### Configuration options
|
|
57
|
+
|
|
58
|
+
For a setup which logs everything to the console in a pretty (colored) output, simply do:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from mh_structlog import *
|
|
62
|
+
|
|
63
|
+
setup(
|
|
64
|
+
log_format='console',
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
getLogger().info('hey')
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
To log as json:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from mh_structlog import *
|
|
74
|
+
|
|
75
|
+
setup(
|
|
76
|
+
log_format='json',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
getLogger().info('hey')
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To filter everything out up to a certain level:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from mh_structlog import *
|
|
86
|
+
|
|
87
|
+
setup(
|
|
88
|
+
log_format='console',
|
|
89
|
+
global_filter_level=WARNING,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
getLogger().info('hey') # this does not get printed
|
|
93
|
+
getLogger().error('hey') # this does get printed
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
To write logs to a file additionally (next to stdout):
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from mh_structlog import *
|
|
100
|
+
|
|
101
|
+
setup(
|
|
102
|
+
log_format='console',
|
|
103
|
+
log_file='myfile.log',
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
getLogger().info('hey')
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
To silence specific named loggers specifically (instead of setting the log level globally, it can be done per named logger):
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from mh_structlog import *
|
|
113
|
+
|
|
114
|
+
setup(
|
|
115
|
+
log_format='console',
|
|
116
|
+
logging_configs=[
|
|
117
|
+
filter_named_logger('some_named_logger', WARNING),
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
getLogger('some_named_logger').info('hey') # does not get logged
|
|
122
|
+
getLogger('some_named_logger').warning('hey') # does get logged
|
|
123
|
+
|
|
124
|
+
getLogger('some_other_named_logger').info('hey') # does get logged
|
|
125
|
+
getLogger('some_other_named_logger').warning('hey') # does get logged
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
To include the source information about where a log was produced:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from mh_structlog import *
|
|
132
|
+
|
|
133
|
+
setup(
|
|
134
|
+
include_source_location=True
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
getLogger().info('hey')
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
To choose how many frames you want to include in stacktraces on logging exceptions:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from mh_structlog import *
|
|
144
|
+
|
|
145
|
+
setup(
|
|
146
|
+
log_format='json',
|
|
147
|
+
max_frames=3,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
5 / 0
|
|
152
|
+
except Exception as e:
|
|
153
|
+
getLogger().exception(e)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
To enable Sentry integration, pass a dict with a config according to the arguments which [structlog-sentry](https://github.com/kiwicom/structlog-sentry?tab=readme-ov-file#usage) allows to the setup function:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from mh_structlog import *
|
|
160
|
+
import sentry_sdk
|
|
161
|
+
|
|
162
|
+
config = {'dsn': '1234'}
|
|
163
|
+
sentry_sdk.init(dsn=config['dsn'])
|
|
164
|
+
|
|
165
|
+
setup(
|
|
166
|
+
sentry_config={'event_level': WARNING} # pass everything starting from WARNING level to Sentry
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
```
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.9.17,<0.10.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mh_structlog"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Mathieu Hinderyckx", email = "mathieu.hinderyckx@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
description = "Some Structlog configuration and wrappers to easily use structlog."
|
|
11
|
+
version = "0.0.40"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"orjson>=3.11.5",
|
|
21
|
+
"rich>=14.0.0",
|
|
22
|
+
"structlog>=25.5.0",
|
|
23
|
+
"structlog-sentry>=2.2.1",
|
|
24
|
+
]
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
django = [
|
|
27
|
+
"django>=5.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools_scm]
|
|
31
|
+
version_file = "src/mh_structlog/_version.py"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = ["ipdb>=0.13.13", "ipython>=8.18.1", "pre-commit>=4.0.1", "ruff>=0.12.1"]
|
|
35
|
+
tests = [
|
|
36
|
+
"coverage>=7.5.3",
|
|
37
|
+
"pytest>=9.0.2",
|
|
38
|
+
"pytest-cov>=7.0.0",
|
|
39
|
+
"pytest-django>=4.11.1",
|
|
40
|
+
"pytest-env>=1.2.0",
|
|
41
|
+
"pytest-randomly>=3.15.0",
|
|
42
|
+
"pytest-sugar>=1.1.1",
|
|
43
|
+
"django>=5.2",
|
|
44
|
+
"freezegun>=1.5.5",
|
|
45
|
+
]
|
|
46
|
+
pages = [
|
|
47
|
+
"black>=24.10.0",
|
|
48
|
+
"mkdocs-literate-nav>=0.6.1",
|
|
49
|
+
"mkdocs-gen-files>=0.5.0",
|
|
50
|
+
"mkdocstrings[python]>=0.26.2",
|
|
51
|
+
"pymdown-extensions>=10.12",
|
|
52
|
+
"mkdocs-include-dir-to-nav>=1.2.0",
|
|
53
|
+
"mkdocs-git-authors-plugin>=0.9.2",
|
|
54
|
+
"mkdocs-glightbox>=0.4.0",
|
|
55
|
+
"mkdocs-git-revision-date-localized-plugin>=1.3.0",
|
|
56
|
+
"mkdocs-roamlinks-plugin>=0.3.2",
|
|
57
|
+
"mkdocs>=1.6.1",
|
|
58
|
+
"mkdocs-material>=9.5.44",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.uv]
|
|
62
|
+
required-version = ">=0.9.11"
|
|
63
|
+
package = true
|
|
64
|
+
cache-keys = [{ file = "pyproject.toml" }]
|
|
65
|
+
environments = [
|
|
66
|
+
"sys_platform == 'darwin'",
|
|
67
|
+
"sys_platform == 'linux'",
|
|
68
|
+
"sys_platform == 'win32'",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# ==== ruff ====
|
|
72
|
+
[tool.ruff]
|
|
73
|
+
line-length = 120
|
|
74
|
+
indent-width = 4
|
|
75
|
+
preview = false
|
|
76
|
+
required-version = ">=0.4.5"
|
|
77
|
+
target-version = "py39"
|
|
78
|
+
unsafe-fixes = true
|
|
79
|
+
|
|
80
|
+
# Overwritten in CI/CD
|
|
81
|
+
output-format = "full"
|
|
82
|
+
fix = true
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint]
|
|
85
|
+
preview = true
|
|
86
|
+
select = [
|
|
87
|
+
# https://docs.astral.sh/ruff/rules/#rules
|
|
88
|
+
"A",
|
|
89
|
+
"AIR",
|
|
90
|
+
"ANN",
|
|
91
|
+
"ARG",
|
|
92
|
+
"ASYNC",
|
|
93
|
+
"B",
|
|
94
|
+
"BLE",
|
|
95
|
+
"C4",
|
|
96
|
+
"C90",
|
|
97
|
+
"COM",
|
|
98
|
+
# "D",
|
|
99
|
+
"DJ",
|
|
100
|
+
"DOC",
|
|
101
|
+
"DTZ",
|
|
102
|
+
"E",
|
|
103
|
+
"EM",
|
|
104
|
+
"EXE",
|
|
105
|
+
"F",
|
|
106
|
+
"FA",
|
|
107
|
+
"FBT",
|
|
108
|
+
"FLY",
|
|
109
|
+
"FURB",
|
|
110
|
+
"G",
|
|
111
|
+
"I",
|
|
112
|
+
"ICN",
|
|
113
|
+
"INP",
|
|
114
|
+
"INT",
|
|
115
|
+
"ISC",
|
|
116
|
+
"LOG",
|
|
117
|
+
"N",
|
|
118
|
+
"NPY",
|
|
119
|
+
"PD",
|
|
120
|
+
"PERF",
|
|
121
|
+
"PGH",
|
|
122
|
+
"PIE",
|
|
123
|
+
"PL",
|
|
124
|
+
"PT",
|
|
125
|
+
"PTH",
|
|
126
|
+
"PYI",
|
|
127
|
+
"Q",
|
|
128
|
+
"R",
|
|
129
|
+
"RET",
|
|
130
|
+
"RSE",
|
|
131
|
+
"RUF",
|
|
132
|
+
"S",
|
|
133
|
+
"SIM",
|
|
134
|
+
"SLF",
|
|
135
|
+
"SLOT",
|
|
136
|
+
"T10",
|
|
137
|
+
"T20",
|
|
138
|
+
"TC",
|
|
139
|
+
"TID",
|
|
140
|
+
"TRY",
|
|
141
|
+
"UP",
|
|
142
|
+
"YTT",
|
|
143
|
+
# "ERA", # commented out code; not always best to have it removed forcefully, sometimes want to keep
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
ignore = [
|
|
147
|
+
"ANN002", # Missing type annotations for *args
|
|
148
|
+
"ANN003", # Missing type annotations for **kwargs
|
|
149
|
+
"ANN204", # Missing return type annotation for special method `__init__`
|
|
150
|
+
"ANN401", # Disallow Any type hints
|
|
151
|
+
"COM812", # Trailing comma missing. The ruff format always removes this on e.g. a list of function arguments.
|
|
152
|
+
"D100", # Missing docstring in public module
|
|
153
|
+
"D105", # Missing docstring in magic method
|
|
154
|
+
"D106", # Missing docstring in public nested class
|
|
155
|
+
"D202", # No blank line after function docstring
|
|
156
|
+
"E501", # Line too long
|
|
157
|
+
"EM101", # Exception must not use a string literal, assign to variable first
|
|
158
|
+
"EM102", # Exception must not use an f-string literal, assign to variable first
|
|
159
|
+
"ISC001", # Conflicting with formatter
|
|
160
|
+
"PD011", # pandas-use-of-dot-values. Ignored because it gives a lot of false positives every time .values is used on something.
|
|
161
|
+
"Q000", # Single quotes found but double quotes preferred
|
|
162
|
+
"RUF012", # mutable-class-default. Class attributes would required type annotations everywhere if enabled.
|
|
163
|
+
"TRY002", # Create your own exception
|
|
164
|
+
"TRY003", # Avoid specifying long messages outside the exception class
|
|
165
|
+
"PGH003", # Require specific codes when silencing linting.
|
|
166
|
+
"FBT001", # Boolean arguments in function calls
|
|
167
|
+
"FBT002", # Boolean arguments in function calls
|
|
168
|
+
"RUF100", # Report unused noqa directives. This clashes with the leniency from the ignore above this one.
|
|
169
|
+
"DOC501", # Raised exception {id} missing from docstring
|
|
170
|
+
"PLR6301", # Not using self in a method
|
|
171
|
+
"DOC201", # Not mentioning 'return' explicitly in a docstring
|
|
172
|
+
"A005", # Module name is shadowing a python builtin module
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
176
|
+
fixable = ["ALL"]
|
|
177
|
+
unfixable = ["T201"]
|
|
178
|
+
|
|
179
|
+
# Allow unused variables when underscore-prefixed.
|
|
180
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
181
|
+
|
|
182
|
+
[tool.ruff.format]
|
|
183
|
+
preview = false
|
|
184
|
+
line-ending = "auto"
|
|
185
|
+
indent-style = "space"
|
|
186
|
+
quote-style = "preserve"
|
|
187
|
+
docstring-code-format = true
|
|
188
|
+
docstring-code-line-length = "dynamic"
|
|
189
|
+
exclude = ["*.pyi", "__pypackages__/", ".venv/", "env/", "venv/", ".git/", ".hg/", ".mypy_cache/", ".tox/", ".nox/", ".pytest_cache/", ".ruff_cache/"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Like Black, respect magic trailing commas.
|
|
193
|
+
skip-magic-trailing-comma = true
|
|
194
|
+
|
|
195
|
+
# 4. Ignore `E402` (import violations) in all `__init__.py` files, and in select subdirectories.
|
|
196
|
+
[tool.ruff.lint.per-file-ignores]
|
|
197
|
+
"__init__.py" = ["E402", "D104", "F403"]
|
|
198
|
+
"**/{tests,docs,tools}/*" = [
|
|
199
|
+
"ANN201",
|
|
200
|
+
"ARG001",
|
|
201
|
+
"ARG002",
|
|
202
|
+
"D101",
|
|
203
|
+
"D102",
|
|
204
|
+
"D103",
|
|
205
|
+
"DOC402",
|
|
206
|
+
"DJ008",
|
|
207
|
+
"E402",
|
|
208
|
+
"PLC2701",
|
|
209
|
+
"PLR0914",
|
|
210
|
+
"PLR0915",
|
|
211
|
+
"PLR2004",
|
|
212
|
+
"S101",
|
|
213
|
+
"S105",
|
|
214
|
+
"S311",
|
|
215
|
+
"SLF001",
|
|
216
|
+
]
|
|
217
|
+
"test*.py" = [
|
|
218
|
+
"ANN201",
|
|
219
|
+
"ARG001",
|
|
220
|
+
"ARG002",
|
|
221
|
+
"D101",
|
|
222
|
+
"D102",
|
|
223
|
+
"D103",
|
|
224
|
+
"DOC402",
|
|
225
|
+
"DJ008",
|
|
226
|
+
"E402",
|
|
227
|
+
"PLC2701",
|
|
228
|
+
"PLR0914",
|
|
229
|
+
"PLR0915",
|
|
230
|
+
"PLR2004",
|
|
231
|
+
"S101",
|
|
232
|
+
"S105",
|
|
233
|
+
"S311",
|
|
234
|
+
"SLF001",
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
[tool.ruff.lint.pylint]
|
|
238
|
+
max-args = 20
|
|
239
|
+
max-positional-args = 20
|
|
240
|
+
|
|
241
|
+
[tool.ruff.lint.pydocstyle]
|
|
242
|
+
convention = "google"
|
|
243
|
+
|
|
244
|
+
[tool.ruff.lint.flake8-quotes]
|
|
245
|
+
docstring-quotes = "double"
|
|
246
|
+
avoid-escape = true
|
|
247
|
+
|
|
248
|
+
[tool.ruff.lint.flake8-annotations]
|
|
249
|
+
allow-star-arg-any = true
|
|
250
|
+
ignore-fully-untyped = true # allows for gradual typing
|
|
251
|
+
suppress-none-returning = false
|
|
252
|
+
|
|
253
|
+
[tool.ruff.lint.flake8-unused-arguments]
|
|
254
|
+
ignore-variadic-names = true
|
|
255
|
+
|
|
256
|
+
[tool.ruff.lint.isort]
|
|
257
|
+
force-single-line = false
|
|
258
|
+
combine-as-imports = true
|
|
259
|
+
force-wrap-aliases = false
|
|
260
|
+
detect-same-package = true
|
|
261
|
+
split-on-trailing-comma = false
|
|
262
|
+
known-local-folder = ["src"]
|
|
263
|
+
lines-after-imports = 2
|
|
264
|
+
|
|
265
|
+
# ==== pytest ====
|
|
266
|
+
[tool.pytest_env]
|
|
267
|
+
ENVIRONMENT = "test"
|
|
268
|
+
COLOR = 1
|
|
269
|
+
|
|
270
|
+
[tool.pytest]
|
|
271
|
+
addopts = ["-s", "--durations=5", "-v", "--import-mode=importlib", "--cov", "--cov-report=term", "--cov-report=html", "--cov-report=xml", "--junit-xml=junit.xml"]
|
|
272
|
+
|
|
273
|
+
junit_family = 'xunit2'
|
|
274
|
+
junit_suite_name = "test_suite"
|
|
275
|
+
junit_logging = 'all'
|
|
276
|
+
junit_log_passing_tests = true
|
|
277
|
+
junit_duration_report = "call"
|
|
278
|
+
|
|
279
|
+
python_files = ["*/tests.py", "*/test_*.py", "*/tests_*.py", "*/*.py"]
|
|
280
|
+
testpaths = ["tests", "src"]
|
|
281
|
+
|
|
282
|
+
filterwarnings = [
|
|
283
|
+
"ignore::DeprecationWarning",
|
|
284
|
+
"ignore::PendingDeprecationWarning",
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
# ==== Coverage ====
|
|
288
|
+
[tool.coverage.run]
|
|
289
|
+
omit = [
|
|
290
|
+
"*/migrations/*",
|
|
291
|
+
"*/media/*",
|
|
292
|
+
".venv/*",
|
|
293
|
+
"terraform/*",
|
|
294
|
+
"htmlcov/*",
|
|
295
|
+
"staticfiles/*",
|
|
296
|
+
"requirements/*",
|
|
297
|
+
"manage.py",
|
|
298
|
+
"asgi.py",
|
|
299
|
+
"wsgi.py",
|
|
300
|
+
"*/.vscode/*",
|
|
301
|
+
]
|
|
302
|
+
plugins = []
|
|
303
|
+
branch = true
|
|
304
|
+
|
|
305
|
+
[tool.coverage.report]
|
|
306
|
+
format = "text"
|
|
307
|
+
skip_empty = false
|
|
308
|
+
precision = 2
|
|
309
|
+
|
|
310
|
+
[tool.coverage.term]
|
|
311
|
+
|
|
312
|
+
[tool.coverage.html]
|
|
313
|
+
directory = "htmlcov"
|
|
314
|
+
|
|
315
|
+
[tool.coverage.xml]
|
|
316
|
+
output = "coverage.xml"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import CRITICAL, DEBUG, ERROR, FATAL, INFO, WARN, WARNING
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
|
|
7
|
+
from .config import filter_named_logger, setup
|
|
8
|
+
from .utils import determine_name_for_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def getLogger(name: str | None = None): # noqa: ANN201, N802
|
|
12
|
+
"""Return a named logger."""
|
|
13
|
+
if name is None:
|
|
14
|
+
name = determine_name_for_logger()
|
|
15
|
+
return structlog.get_logger(name)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_logger(name: str | None = None): # noqa: ANN201
|
|
19
|
+
"""Return a named logger."""
|
|
20
|
+
return getLogger(name)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CRITICAL",
|
|
25
|
+
"DEBUG",
|
|
26
|
+
"ERROR",
|
|
27
|
+
"FATAL",
|
|
28
|
+
"INFO",
|
|
29
|
+
"WARN",
|
|
30
|
+
"WARNING",
|
|
31
|
+
"filter_named_logger",
|
|
32
|
+
"getLogger",
|
|
33
|
+
"getLogger",
|
|
34
|
+
"get_logger",
|
|
35
|
+
"get_logger",
|
|
36
|
+
"setup",
|
|
37
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging # noqa: I001
|
|
4
|
+
import logging.config
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from structlog.dev import RichTracebackFormatter
|
|
11
|
+
from structlog.processors import CallsiteParameter
|
|
12
|
+
|
|
13
|
+
from . import processors
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SELECTED_LOG_FORMAT = 'console'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StructlogLoggingConfigExceptionError(Exception):
|
|
20
|
+
"""Exception to raise if the config is not correct."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def setup( # noqa: PLR0912, PLR0915, C901
|
|
24
|
+
log_format: Literal["console", "json", "gcp_json"] | None = None,
|
|
25
|
+
logging_configs: list[dict] | None = None,
|
|
26
|
+
include_source_location: bool = False, # noqa: FBT001, FBT002
|
|
27
|
+
global_filter_level: int | None = None,
|
|
28
|
+
log_file: str | Path | None = None,
|
|
29
|
+
log_file_format: Literal["console", "json"] | None = None,
|
|
30
|
+
testing_mode: bool = False, # noqa: FBT001, FBT002
|
|
31
|
+
max_frames: int = 100,
|
|
32
|
+
sentry_config: dict | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""This method configures structlog and the standard library logging module."""
|
|
35
|
+
global SELECTED_LOG_FORMAT # noqa: PLW0603
|
|
36
|
+
|
|
37
|
+
# Unless we are in testing mode, don't configure logging if it was already configured.
|
|
38
|
+
# During testing, we need te flexibility to configure logging multiple times.
|
|
39
|
+
if structlog.is_configured() and not testing_mode:
|
|
40
|
+
from logging import getLogger # noqa: PLC0415
|
|
41
|
+
|
|
42
|
+
getLogger('mh_structlog').warning('logging was already configured, so I return and do nothing.')
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
shared_processors = [
|
|
46
|
+
structlog.stdlib.add_logger_name, # add the logger name
|
|
47
|
+
structlog.stdlib.add_log_level, # add the log level as textual representation
|
|
48
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True), # add a timestamp
|
|
49
|
+
structlog.contextvars.merge_contextvars, # add variables and bound data from global context
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
if max_frames <= 0:
|
|
53
|
+
raise StructlogLoggingConfigExceptionError("max_frames should be a positive integer.")
|
|
54
|
+
|
|
55
|
+
# Configure stdout formatter
|
|
56
|
+
if log_format is None:
|
|
57
|
+
log_format = "console" if sys.stdout.isatty() else "json"
|
|
58
|
+
if log_format not in {"console", "json", "gcp_json"}:
|
|
59
|
+
raise StructlogLoggingConfigExceptionError("Unknown logging format requested.")
|
|
60
|
+
|
|
61
|
+
SELECTED_LOG_FORMAT = log_format
|
|
62
|
+
|
|
63
|
+
if sentry_config and sentry_config.get('active', True):
|
|
64
|
+
# By default, ignore our own request access logger (which is only used when you use the Django access logger from this package in your project).
|
|
65
|
+
# When a request errors, there are normally other exceptions that show up in Sentry for it; adding the
|
|
66
|
+
# access log at the end only results in a duplicate event.
|
|
67
|
+
#
|
|
68
|
+
# When you specify ignore_loggers manually, it is not ignored anymore, so you should add it yourself (when wanted).
|
|
69
|
+
sentry_config.setdefault('ignore_loggers', ['mh_structlog.django.access'])
|
|
70
|
+
shared_processors.append(processors.SentryProcessor(**sentry_config))
|
|
71
|
+
else:
|
|
72
|
+
# In case logging statements add sentry_skip, but Sentry isn't configured at all, we do not want to output that key.
|
|
73
|
+
shared_processors.append(processors.FieldDropper(['sentry_skip']))
|
|
74
|
+
|
|
75
|
+
if log_format == "console":
|
|
76
|
+
selected_formatter = "mh_structlog_colored"
|
|
77
|
+
elif log_format in {"json", "gcp_json"}:
|
|
78
|
+
shared_processors.extend(
|
|
79
|
+
[structlog.processors.dict_tracebacks, processors.CapExceptionFrames(max_frames=2 * max_frames)]
|
|
80
|
+
)
|
|
81
|
+
selected_formatter = "mh_structlog_json"
|
|
82
|
+
|
|
83
|
+
if include_source_location:
|
|
84
|
+
shared_processors.append(
|
|
85
|
+
structlog.processors.CallsiteParameterAdder(
|
|
86
|
+
parameters={CallsiteParameter.PATHNAME, CallsiteParameter.LINENO, CallsiteParameter.FUNC_NAME}
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
wrapper_class = structlog.stdlib.BoundLogger
|
|
91
|
+
if global_filter_level is not None:
|
|
92
|
+
wrapper_class = structlog.make_filtering_bound_logger(global_filter_level)
|
|
93
|
+
|
|
94
|
+
# Structlog configuration
|
|
95
|
+
structlog.configure(
|
|
96
|
+
processors=[
|
|
97
|
+
*shared_processors,
|
|
98
|
+
structlog.stdlib.filter_by_level, # filter based on the stdlib logging config
|
|
99
|
+
structlog.stdlib.PositionalArgumentsFormatter(), # Allow string formatting with positional arguments in log calls
|
|
100
|
+
structlog.processors.StackInfoRenderer(
|
|
101
|
+
additional_ignores=['mh_structlog']
|
|
102
|
+
), # when you create a log and specify stack_info=True, add a stacktrace to the log
|
|
103
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
104
|
+
],
|
|
105
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
106
|
+
wrapper_class=wrapper_class,
|
|
107
|
+
cache_logger_on_first_use=not testing_mode, # https://www.structlog.org/en/stable/testing.html#testing
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Std lib logging configuration.
|
|
111
|
+
stdlib_logging_config = {
|
|
112
|
+
"version": 1,
|
|
113
|
+
"disable_existing_loggers": False,
|
|
114
|
+
"formatters": {
|
|
115
|
+
"mh_structlog_plain": {
|
|
116
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
|
117
|
+
"processors": [
|
|
118
|
+
processors.add_flattened_extra, # extract the content of 'extra' and add it as entries in the event dict
|
|
119
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta, # remove some fields used by structlogs internal logic
|
|
120
|
+
structlog.processors.EventRenamer("message"),
|
|
121
|
+
structlog.dev.ConsoleRenderer(
|
|
122
|
+
colors=False,
|
|
123
|
+
force_colors=False,
|
|
124
|
+
pad_event_to=80,
|
|
125
|
+
sort_keys=True,
|
|
126
|
+
event_key="message",
|
|
127
|
+
exception_formatter=RichTracebackFormatter(
|
|
128
|
+
width=None, max_frames=max_frames, show_locals=True, locals_hide_dunder=True
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
"foreign_pre_chain": shared_processors,
|
|
133
|
+
},
|
|
134
|
+
"mh_structlog_colored": {
|
|
135
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
|
136
|
+
"processors": [
|
|
137
|
+
processors.add_flattened_extra, # extract the content of 'extra' and add it as entries in the event dict
|
|
138
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta, # remove some fields used by structlogs internal logic
|
|
139
|
+
structlog.processors.EventRenamer("message"),
|
|
140
|
+
structlog.dev.ConsoleRenderer(
|
|
141
|
+
colors=True,
|
|
142
|
+
pad_event_to=80,
|
|
143
|
+
sort_keys=True,
|
|
144
|
+
event_key="message",
|
|
145
|
+
exception_formatter=RichTracebackFormatter(
|
|
146
|
+
width=None, max_frames=max_frames, show_locals=True, locals_hide_dunder=True
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
],
|
|
150
|
+
"foreign_pre_chain": shared_processors,
|
|
151
|
+
},
|
|
152
|
+
"mh_structlog_json": {
|
|
153
|
+
"()": structlog.stdlib.ProcessorFormatter,
|
|
154
|
+
"processors": [
|
|
155
|
+
processors.add_flattened_extra, # extract the content of 'extra' and add it as entries in the event dict
|
|
156
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta, # remove some fields used by structlogs internal logic
|
|
157
|
+
structlog.processors.EventRenamer("message"),
|
|
158
|
+
processors.FieldRenamer(
|
|
159
|
+
log_format == 'gcp_json', 'level', 'severity'
|
|
160
|
+
), # rename the level field for GCP
|
|
161
|
+
processors.render_orjson,
|
|
162
|
+
],
|
|
163
|
+
"foreign_pre_chain": shared_processors,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
"filters": {},
|
|
167
|
+
"handlers": {
|
|
168
|
+
"mh_structlog_stdout": {
|
|
169
|
+
"level": "DEBUG" if global_filter_level is None else logging.getLevelName(global_filter_level),
|
|
170
|
+
"class": "logging.StreamHandler",
|
|
171
|
+
"stream": "ext://sys.stdout",
|
|
172
|
+
"formatter": selected_formatter,
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"loggers": {
|
|
176
|
+
"": {
|
|
177
|
+
"handlers": ["mh_structlog_stdout"],
|
|
178
|
+
"level": "DEBUG" if global_filter_level is None else logging.getLevelName(global_filter_level),
|
|
179
|
+
"propagate": True,
|
|
180
|
+
},
|
|
181
|
+
"stdout": {
|
|
182
|
+
"handlers": ["mh_structlog_stdout"],
|
|
183
|
+
"level": "DEBUG" if global_filter_level is None else logging.getLevelName(global_filter_level),
|
|
184
|
+
"propagate": False,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Add a handler to output to a file
|
|
190
|
+
if log_file:
|
|
191
|
+
# Select formatter
|
|
192
|
+
if log_file_format is None:
|
|
193
|
+
log_file_format = "console" if sys.stdout.isatty() else "json"
|
|
194
|
+
if log_file_format not in {"console", "json"}:
|
|
195
|
+
raise StructlogLoggingConfigExceptionError("Unknown logging format requested.")
|
|
196
|
+
|
|
197
|
+
if log_file_format == "console":
|
|
198
|
+
selected_file_formatter = "mh_structlog_plain"
|
|
199
|
+
elif log_file_format == "json":
|
|
200
|
+
selected_file_formatter = "mh_structlog_json"
|
|
201
|
+
|
|
202
|
+
log_file = Path(log_file)
|
|
203
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
|
|
205
|
+
# Add a handler with file output to the root logger
|
|
206
|
+
stdlib_logging_config['handlers']['mh_structlog_file'] = {
|
|
207
|
+
"level": "DEBUG" if global_filter_level is None else logging.getLevelName(global_filter_level),
|
|
208
|
+
"class": "logging.FileHandler",
|
|
209
|
+
"formatter": selected_file_formatter,
|
|
210
|
+
'filename': str(log_file.resolve()),
|
|
211
|
+
}
|
|
212
|
+
stdlib_logging_config['loggers']['']['handlers'].append('mh_structlog_file')
|
|
213
|
+
# Add a named logger to log to the file only (the root logger logs to both stdout and file)
|
|
214
|
+
stdlib_logging_config['loggers']['file'] = {
|
|
215
|
+
"handlers": ["mh_structlog_file"],
|
|
216
|
+
"level": "DEBUG" if global_filter_level is None else logging.getLevelName(global_filter_level),
|
|
217
|
+
"propagate": False,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Merge in additional logging configs that were passed in by the caller.
|
|
221
|
+
if logging_configs:
|
|
222
|
+
for lc in logging_configs:
|
|
223
|
+
for k, v in lc.get("loggers", {}).items():
|
|
224
|
+
if k in {"", "root"}:
|
|
225
|
+
raise StructlogLoggingConfigExceptionError(
|
|
226
|
+
"It is not allowed to specify a custom root logger, since structlog configures that one."
|
|
227
|
+
)
|
|
228
|
+
# Add our handler if none was specified explicitly
|
|
229
|
+
if "handlers" not in v:
|
|
230
|
+
v["handlers"] = ["mh_structlog_stdout"]
|
|
231
|
+
if log_file:
|
|
232
|
+
v['handlers'].append('mh_structlog_file')
|
|
233
|
+
if "level" not in v:
|
|
234
|
+
v["level"] = "DEBUG" if global_filter_level is None else logging.getLevelName(global_filter_level)
|
|
235
|
+
v["propagate"] = False
|
|
236
|
+
stdlib_logging_config["loggers"][k] = v
|
|
237
|
+
for k, v in lc.get("handlers", {}).items():
|
|
238
|
+
# Set the formatter to ours if none was specified explicitly
|
|
239
|
+
if "formatter" not in v:
|
|
240
|
+
# If we are logging to a file and we do not do json format, use the non-colored formatter
|
|
241
|
+
if "file" in v["class"].lower() and selected_formatter == "mh_structlog_colored":
|
|
242
|
+
v["formatter"] = "mh_structlog_plain"
|
|
243
|
+
else:
|
|
244
|
+
v["formatter"] = selected_formatter
|
|
245
|
+
stdlib_logging_config["handlers"][k] = v
|
|
246
|
+
for k, v in lc.get("formatters", {}).items():
|
|
247
|
+
if k in {"mh_structlog_plain", "mh_structlog_colored", "mh_structlog_json"}:
|
|
248
|
+
raise StructlogLoggingConfigExceptionError(
|
|
249
|
+
f"It is not allowed to specify a formatter with the name {k}, since structlog configures that one."
|
|
250
|
+
)
|
|
251
|
+
stdlib_logging_config["formatters"][k] = v
|
|
252
|
+
for k, v in lc.get("filters", {}).items():
|
|
253
|
+
stdlib_logging_config["filters"][k] = v
|
|
254
|
+
|
|
255
|
+
logging.config.dictConfig(stdlib_logging_config)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def filter_named_logger(logger_name: str, level: int) -> dict:
|
|
259
|
+
"""Return a dict containing a configuration for a named logger with a certain level filter.
|
|
260
|
+
|
|
261
|
+
Use this to silence a named logger by passing this config to the setup() method.
|
|
262
|
+
"""
|
|
263
|
+
# fmt: off
|
|
264
|
+
return {
|
|
265
|
+
"loggers": {
|
|
266
|
+
logger_name: {
|
|
267
|
+
"level": level,
|
|
268
|
+
"propagate": False,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
# fmt: on
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from asgiref.sync import iscoroutinefunction
|
|
5
|
+
from django.http import HttpRequest, HttpResponse
|
|
6
|
+
from django.utils.decorators import sync_and_async_middleware
|
|
7
|
+
|
|
8
|
+
from mh_structlog.config import SELECTED_LOG_FORMAT # noqa: PLC0415
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = structlog.getLogger("mh_structlog.django.access")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_fields_to_log(request: HttpRequest, response: HttpResponse, latency_ms: int) -> dict:
|
|
15
|
+
"""Extracts fields to log from the request object."""
|
|
16
|
+
|
|
17
|
+
fields_to_log = {'latency_ms': latency_ms, 'method': request.method, 'status': response.status_code}
|
|
18
|
+
|
|
19
|
+
if SELECTED_LOG_FORMAT == 'gcp_json':
|
|
20
|
+
fields_to_log['httpRequest'] = {
|
|
21
|
+
'requestMethod': request.method,
|
|
22
|
+
'requestUrl': request.build_absolute_uri(),
|
|
23
|
+
'status': response.status_code,
|
|
24
|
+
'latency': f"{latency_ms / 1000}s",
|
|
25
|
+
"userAgent": request.headers.get('User-Agent', ''),
|
|
26
|
+
"responseSize": str(response.headers.get('Content-Length', 0)),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return fields_to_log
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@sync_and_async_middleware
|
|
33
|
+
def StructLogAccessLoggingMiddleware(get_response): # noqa: N802
|
|
34
|
+
"""Middleware that logs access requests with some extra fields as structured logs."""
|
|
35
|
+
|
|
36
|
+
if iscoroutinefunction(get_response):
|
|
37
|
+
|
|
38
|
+
async def middleware(request):
|
|
39
|
+
start = time.time()
|
|
40
|
+
response = await get_response(request)
|
|
41
|
+
end = time.time()
|
|
42
|
+
|
|
43
|
+
latency_ms = int(1000 * (end - start))
|
|
44
|
+
fields_to_log = get_fields_to_log(request, response, latency_ms)
|
|
45
|
+
|
|
46
|
+
# in case Sentry is enabled, prevent logging to it.
|
|
47
|
+
# The actual exception will be logged if necessary somewhere else, but the response access log to the client should not be on there.
|
|
48
|
+
|
|
49
|
+
if response.status_code >= 500: # noqa: PLR2004
|
|
50
|
+
await logger.aerror(request.get_full_path(), sentry_skip=True, **fields_to_log)
|
|
51
|
+
elif response.status_code >= 400: # noqa: PLR2004
|
|
52
|
+
await logger.awarning(request.get_full_path(), sentry_skip=True, **fields_to_log)
|
|
53
|
+
else:
|
|
54
|
+
await logger.ainfo(request.get_full_path(), **fields_to_log)
|
|
55
|
+
|
|
56
|
+
return response
|
|
57
|
+
|
|
58
|
+
else:
|
|
59
|
+
|
|
60
|
+
def middleware(request):
|
|
61
|
+
start = time.time()
|
|
62
|
+
response = get_response(request)
|
|
63
|
+
end = time.time()
|
|
64
|
+
|
|
65
|
+
latency_ms = int(1000 * (end - start))
|
|
66
|
+
fields_to_log = get_fields_to_log(request, response, latency_ms)
|
|
67
|
+
|
|
68
|
+
# in case Sentry is enabled, prevent logging to it.
|
|
69
|
+
# The actual exception will be logged if necessary somewhere else, but the response access log to the client should not be on there.
|
|
70
|
+
|
|
71
|
+
if response.status_code >= 500: # noqa: PLR2004
|
|
72
|
+
logger.error(request.get_full_path(), sentry_skip=True, **fields_to_log)
|
|
73
|
+
elif response.status_code >= 400: # noqa: PLR2004
|
|
74
|
+
logger.warning(request.get_full_path(), sentry_skip=True, **fields_to_log)
|
|
75
|
+
else:
|
|
76
|
+
logger.info(request.get_full_path(), **fields_to_log)
|
|
77
|
+
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
return middleware
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import orjson
|
|
4
|
+
import structlog
|
|
5
|
+
from structlog.processors import CallsiteParameter
|
|
6
|
+
from structlog.typing import EventDict
|
|
7
|
+
from structlog_sentry import SentryProcessor as _SentryProcessor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Inspect a default logging library record so we can find out which keys on a LogRecord are 'extra' and not default ones.
|
|
11
|
+
_LOG_RECORD_KEYS = set(logging.LogRecord("name", 0, "pathname", 0, "msg", (), None).__dict__.keys())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_flattened_extra(_, __, event_dict: dict) -> dict: # noqa: ANN001
|
|
15
|
+
"""Include the content of 'extra' in the output log, flattened the attributes."""
|
|
16
|
+
if event_dict.get("_from_structlog"):
|
|
17
|
+
# Coming from structlog logging call
|
|
18
|
+
extra = event_dict.pop("extra", {})
|
|
19
|
+
event_dict.update(extra)
|
|
20
|
+
else:
|
|
21
|
+
# Coming from standard logging call
|
|
22
|
+
record = event_dict.get("_record")
|
|
23
|
+
if record is not None:
|
|
24
|
+
event_dict.update({k: v for k, v in record.__dict__.items() if k not in _LOG_RECORD_KEYS})
|
|
25
|
+
|
|
26
|
+
return event_dict
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _merge_pathname_lineno_function_to_location(logger: structlog.BoundLogger, name: str, event_dict: dict) -> dict: # noqa: ARG001
|
|
30
|
+
"""Add the source of the log as a single attribute."""
|
|
31
|
+
pathname = event_dict.pop(CallsiteParameter.PATHNAME.value, None)
|
|
32
|
+
lineno = event_dict.pop(CallsiteParameter.LINENO.value, None)
|
|
33
|
+
func_name = event_dict.pop(CallsiteParameter.FUNC_NAME.value, None)
|
|
34
|
+
event_dict["location"] = f"{pathname}:{lineno}({func_name})"
|
|
35
|
+
return event_dict
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_orjson(logger: structlog.BoundLogger, name: str, event_dict: dict) -> str: # noqa: ARG001
|
|
39
|
+
"""Render the event_dict as a json string using orjson."""
|
|
40
|
+
return orjson.dumps(event_dict, default=repr).decode()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FieldDropper:
|
|
44
|
+
"""Drop fields from the event dict if present."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, fields: list): # noqa: D107
|
|
47
|
+
self.fields = fields
|
|
48
|
+
|
|
49
|
+
def __call__(self, logger: logging.Logger, name: str, event_dict: EventDict) -> EventDict: # noqa: D102,ARG001,ARG002
|
|
50
|
+
for field in self.fields:
|
|
51
|
+
event_dict.pop(field, None)
|
|
52
|
+
return event_dict
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FieldRenamer:
|
|
56
|
+
"""Rename fields in the event dict."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, enable: bool, name_from: str, name_to: str): # noqa: D107
|
|
59
|
+
self.enable = enable
|
|
60
|
+
self.name_from = name_from
|
|
61
|
+
self.name_to = name_to
|
|
62
|
+
|
|
63
|
+
def __call__(self, logger: logging.Logger, name: str, event_dict: EventDict) -> EventDict: # noqa: D102,ARG001,ARG002
|
|
64
|
+
if self.enable and self.name_from in event_dict:
|
|
65
|
+
event_dict[self.name_to] = event_dict.pop(self.name_from)
|
|
66
|
+
|
|
67
|
+
return event_dict
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CapExceptionFrames:
|
|
71
|
+
"""Limit the number of frames in the exception traceback.
|
|
72
|
+
|
|
73
|
+
With the builtin ConsoleRenderer, this can be given as argument (max_frames), but not when dict_tracebacks is used.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, max_frames: int):
|
|
77
|
+
"""Set the max number of frames to keep in exception tracebacks."""
|
|
78
|
+
self.max_frames = max_frames
|
|
79
|
+
|
|
80
|
+
def __call__(self, logger: structlog.BoundLogger, name: str, event_dict: EventDict) -> EventDict: # noqa: ARG002, D102
|
|
81
|
+
if self.max_frames is not None and 'exception' in event_dict and 'frames' in event_dict["exception"]:
|
|
82
|
+
event_dict['exception']['frames'] = event_dict['exception']['frames'][-self.max_frames :]
|
|
83
|
+
return event_dict
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SentryProcessor(_SentryProcessor):
|
|
87
|
+
"""The SentryProcessor but with some of our own defaults and slight customization applied."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, **kwargs): # noqa: D107
|
|
90
|
+
# Unless otherwise specified, add all extra attributes from the log to Sentry as tags.
|
|
91
|
+
# Explicitly pass tag_keys=None to avoid this behaviour.
|
|
92
|
+
if 'tag_keys' not in kwargs:
|
|
93
|
+
kwargs['tag_keys'] = '__all__'
|
|
94
|
+
super().__init__(**kwargs)
|
|
95
|
+
|
|
96
|
+
def _get_event_and_hint(self, event_dict: EventDict) -> tuple[dict, dict]:
|
|
97
|
+
"""Filter out tag_keys which are not primitive types, because Sentry gives an error otherwise."""
|
|
98
|
+
|
|
99
|
+
event, hint = super()._get_event_and_hint(event_dict)
|
|
100
|
+
|
|
101
|
+
if 'tags' in event:
|
|
102
|
+
for k in list(event['tags'].keys()):
|
|
103
|
+
if not isinstance(event['tags'][k], (bool, str, int, float, type(None))): # noqa: UP038
|
|
104
|
+
del event['tags'][k]
|
|
105
|
+
|
|
106
|
+
return event, hint
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def determine_name_for_logger():
|
|
5
|
+
"""Return a name for a logger depending on the stackframe."""
|
|
6
|
+
frames = inspect.stack()
|
|
7
|
+
|
|
8
|
+
for f in frames:
|
|
9
|
+
frame = f
|
|
10
|
+
if 'mh_structlog' not in f[1]:
|
|
11
|
+
break
|
|
12
|
+
|
|
13
|
+
# Make a name ourselves
|
|
14
|
+
name: str = frame[1].lstrip('/').rstrip('.py').replace('/', '.')
|
|
15
|
+
|
|
16
|
+
# Strip away some common 'prefixes' paths
|
|
17
|
+
for location in ['src', 'code', 'app']:
|
|
18
|
+
if f'{location}.' in name:
|
|
19
|
+
_, _, name = name.partition(f'{location}.')
|
|
20
|
+
break
|
|
21
|
+
|
|
22
|
+
return name
|