aiohttp-request-id-logging 1.0.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.
- aiohttp_request_id_logging/__init__.py +71 -0
- aiohttp_request_id_logging/context.py +24 -0
- aiohttp_request_id_logging/errors.py +15 -0
- aiohttp_request_id_logging/logging_setup.py +66 -0
- aiohttp_request_id_logging/middleware.py +398 -0
- aiohttp_request_id_logging/py.typed +0 -0
- aiohttp_request_id_logging/request_id_factories.py +74 -0
- aiohttp_request_id_logging-1.0.0.dist-info/METADATA +487 -0
- aiohttp_request_id_logging-1.0.0.dist-info/RECORD +12 -0
- aiohttp_request_id_logging-1.0.0.dist-info/WHEEL +5 -0
- aiohttp_request_id_logging-1.0.0.dist-info/licenses/LICENSE +21 -0
- aiohttp_request_id_logging-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adds a request (correlation) id to log messages in aiohttp web applications.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
|
|
6
|
+
from aiohttp.web import Application, Response, RouteTableDef, run_app
|
|
7
|
+
from logging import DEBUG, basicConfig
|
|
8
|
+
from aiohttp_request_id_logging import (
|
|
9
|
+
setup_logging_request_id_prefix,
|
|
10
|
+
RequestIdMiddleware,
|
|
11
|
+
RequestIdAccessLogger,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
routes = RouteTableDef()
|
|
15
|
+
|
|
16
|
+
@routes.get('/')
|
|
17
|
+
async def hello(request):
|
|
18
|
+
return Response(text="Hello, world!")
|
|
19
|
+
|
|
20
|
+
basicConfig(
|
|
21
|
+
level=DEBUG,
|
|
22
|
+
format='%(asctime)s [%(threadName)s] %(name)-37s %(levelname)5s: %(requestIdPrefix)s%(message)s')
|
|
23
|
+
|
|
24
|
+
setup_logging_request_id_prefix()
|
|
25
|
+
|
|
26
|
+
app = Application(middlewares=[RequestIdMiddleware()])
|
|
27
|
+
app.router.add_routes(routes)
|
|
28
|
+
|
|
29
|
+
run_app(app, access_log_class=RequestIdAccessLogger)
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__version__ = "1.0.0"
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import sentry_sdk
|
|
37
|
+
except ImportError:
|
|
38
|
+
sentry_sdk = None # ty: ignore[invalid-assignment]
|
|
39
|
+
|
|
40
|
+
from .context import request_id, REQUEST_ID_KEY, FALLBACK_REQUEST_ID_KEY
|
|
41
|
+
from .errors import RequestIdKeyAlreadySetError
|
|
42
|
+
from .middleware import RequestIdMiddleware, request_id_middleware, noop
|
|
43
|
+
from .request_id_factories import (
|
|
44
|
+
random_request_id_factory,
|
|
45
|
+
sequential_request_id_factory,
|
|
46
|
+
SequentialRequestIdFactory,
|
|
47
|
+
)
|
|
48
|
+
from .logging_setup import setup_logging_request_id_prefix, RequestIdAccessLogger
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# old names for backward compatibility
|
|
52
|
+
generate_request_id = random_request_id_factory
|
|
53
|
+
RequestIdContextAccessLogger = RequestIdAccessLogger
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"RequestIdMiddleware",
|
|
58
|
+
"request_id_middleware",
|
|
59
|
+
"RequestIdKeyAlreadySetError",
|
|
60
|
+
"setup_logging_request_id_prefix",
|
|
61
|
+
"RequestIdAccessLogger",
|
|
62
|
+
"RequestIdContextAccessLogger",
|
|
63
|
+
"random_request_id_factory",
|
|
64
|
+
"generate_request_id",
|
|
65
|
+
"sequential_request_id_factory",
|
|
66
|
+
"SequentialRequestIdFactory",
|
|
67
|
+
"request_id",
|
|
68
|
+
"REQUEST_ID_KEY",
|
|
69
|
+
"FALLBACK_REQUEST_ID_KEY",
|
|
70
|
+
"noop",
|
|
71
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants and context variables shared across the package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from aiohttp import web
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ContextVar that contains given request tracing id,
|
|
10
|
+
# or None outside of a request
|
|
11
|
+
request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
12
|
+
|
|
13
|
+
REQUEST_ID_KEY: "web.RequestKey[str] | str"
|
|
14
|
+
FALLBACK_REQUEST_ID_KEY: str | None
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
# key for storing the request id in the request; aiohttp recommends
|
|
18
|
+
# web.RequestKey instances instead of plain strings
|
|
19
|
+
REQUEST_ID_KEY = web.RequestKey("request_id", str)
|
|
20
|
+
FALLBACK_REQUEST_ID_KEY = "request_id"
|
|
21
|
+
except AttributeError:
|
|
22
|
+
# older aiohttp without web.RequestKey
|
|
23
|
+
REQUEST_ID_KEY = "request_id"
|
|
24
|
+
FALLBACK_REQUEST_ID_KEY = None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class RequestIdKeyAlreadySetError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Raised when the request already contains a request id.
|
|
4
|
+
|
|
5
|
+
This most likely means that request_id_middleware is applied twice,
|
|
6
|
+
or that something else also sets the request id in the request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, existing_request_id: str):
|
|
10
|
+
super().__init__(
|
|
11
|
+
f"The request already contains request id {existing_request_id!r} - "
|
|
12
|
+
"request_id_middleware is most likely applied twice, "
|
|
13
|
+
"or something else also sets the request id"
|
|
14
|
+
)
|
|
15
|
+
self.existing_request_id = existing_request_id
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from aiohttp import web
|
|
4
|
+
from aiohttp.web_log import AccessLogger as _AccessLogger
|
|
5
|
+
|
|
6
|
+
from .context import request_id, REQUEST_ID_KEY
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logging_request_id_prefix(prefix_format: str = "[req:{request_id}] ") -> None:
|
|
10
|
+
"""
|
|
11
|
+
Wrap logging record factory so that every log record gets two extra attributes:
|
|
12
|
+
|
|
13
|
+
- record.requestIdPrefix - "[req:...] ", or an empty string outside of a request
|
|
14
|
+
- record.request_id - the raw request id, or None
|
|
15
|
+
|
|
16
|
+
You can then use them in log format as "%(requestIdPrefix)s" or "%(request_id)s".
|
|
17
|
+
|
|
18
|
+
The prefix can be customized with the prefix_format parameter.
|
|
19
|
+
|
|
20
|
+
Safe to call multiple times - the setup is done only once; subsequent
|
|
21
|
+
calls do nothing, even when called with a different prefix_format
|
|
22
|
+
(the format from the first call stays in effect).
|
|
23
|
+
"""
|
|
24
|
+
# make sure we are doing this only once
|
|
25
|
+
if getattr(logging, "request_id_log_record_factory_set_up", False):
|
|
26
|
+
return
|
|
27
|
+
logging.request_id_log_record_factory_set_up = True # ty: ignore[unresolved-attribute]
|
|
28
|
+
|
|
29
|
+
old_factory = logging.getLogRecordFactory()
|
|
30
|
+
|
|
31
|
+
def new_factory(*args, **kwargs):
|
|
32
|
+
record = old_factory(*args, **kwargs)
|
|
33
|
+
req_id = request_id.get()
|
|
34
|
+
record.request_id = req_id
|
|
35
|
+
record.requestIdPrefix = prefix_format.format(request_id=req_id) if req_id else ""
|
|
36
|
+
return record
|
|
37
|
+
|
|
38
|
+
logging.setLogRecordFactory(new_factory)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RequestIdAccessLogger(_AccessLogger):
|
|
42
|
+
"""
|
|
43
|
+
Subclass of aiohttp.web_log.AccessLogger that sets the request_id
|
|
44
|
+
ContextVar while writing the access log line.
|
|
45
|
+
|
|
46
|
+
Needed because aiohttp writes the access log outside of the middleware
|
|
47
|
+
scope, where the ContextVar is already reset.
|
|
48
|
+
|
|
49
|
+
Usage: run_app(app, access_log_class=RequestIdAccessLogger)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def log(self, request: web.BaseRequest, response: web.StreamResponse, time: float) -> None:
|
|
53
|
+
try:
|
|
54
|
+
request_id_value = request[REQUEST_ID_KEY]
|
|
55
|
+
except KeyError:
|
|
56
|
+
# If there is no request[REQUEST_ID_KEY], for example when an error
|
|
57
|
+
# occurs in a middleware, fall back to just logging without setting
|
|
58
|
+
# the request_id context variable.
|
|
59
|
+
super().log(request, response, time)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
token = request_id.set(request_id_value)
|
|
63
|
+
try:
|
|
64
|
+
super().log(request, response, time)
|
|
65
|
+
finally:
|
|
66
|
+
request_id.reset(token)
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from aiohttp import web
|
|
2
|
+
from aiohttp.typedefs import Handler
|
|
3
|
+
from aiohttp.web_exceptions import HTTPException
|
|
4
|
+
from asyncio import CancelledError
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import AbstractContextManager, ExitStack
|
|
7
|
+
from logging import getLogger
|
|
8
|
+
from typing import Any
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
from .context import REQUEST_ID_KEY, FALLBACK_REQUEST_ID_KEY, request_id as request_id_cv
|
|
12
|
+
from .errors import RequestIdKeyAlreadySetError
|
|
13
|
+
from .request_id_factories import random_request_id_factory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def noop(*args: Any, **kwargs: Any) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Pass this function as add_response_request_id_header or log_request_start to disable the default behavior.
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RequestIdMiddleware:
|
|
27
|
+
"""
|
|
28
|
+
aiohttp middleware that generates a request id for every request,
|
|
29
|
+
stores it in the request_id ContextVar and in the request
|
|
30
|
+
(request[REQUEST_ID_KEY]), adds an X-Request-Id response header,
|
|
31
|
+
and - if sentry_sdk is installed - creates a Sentry scope with
|
|
32
|
+
a request_id tag.
|
|
33
|
+
|
|
34
|
+
Constructor parameters (all keyword-only):
|
|
35
|
+
|
|
36
|
+
- request_id_factory: zero-argument callable returning the request id
|
|
37
|
+
string, used when get_request_id returns None (which the default
|
|
38
|
+
implementation always does); default: random_request_id_factory
|
|
39
|
+
- get_request_id: callable (request) returning the request id for the
|
|
40
|
+
given request, or None to have one generated with request_id_factory;
|
|
41
|
+
if you adopt an incoming header value here, validate it - it is
|
|
42
|
+
controlled by the client
|
|
43
|
+
- log_request_start: callable (request, handler) that logs the request
|
|
44
|
+
start message, replacing the default "Processing GET / (...)" one;
|
|
45
|
+
pass noop to disable the message
|
|
46
|
+
- log_function_name: include the handler name in the default request
|
|
47
|
+
start message; default: True
|
|
48
|
+
- add_response_request_id_header: callable (response, req_id) that adds
|
|
49
|
+
the request id header to the response, replacing the default behavior
|
|
50
|
+
(which keeps a header already set by the handler); pass noop to
|
|
51
|
+
disable the header
|
|
52
|
+
- request_id_header_name: name of the response header with the request
|
|
53
|
+
id; default: "X-Request-Id"
|
|
54
|
+
- no_fallback_request_id_key: if True, store the request id only under
|
|
55
|
+
REQUEST_ID_KEY and not under the backward compatibility plain string
|
|
56
|
+
key request['request_id']; default: False
|
|
57
|
+
|
|
58
|
+
The behavior can also be customized by subclassing - overriding the class
|
|
59
|
+
attributes (request_id_header_name, log_function_name) or the methods:
|
|
60
|
+
get_request_id, before_request, after_request,
|
|
61
|
+
get_response_for_exception, log_request_start, set_request_keys,
|
|
62
|
+
setup_sentry_scope, add_response_request_id_header, get_function_name.
|
|
63
|
+
|
|
64
|
+
Functions stored in class attributes are tricky (Python would bind them
|
|
65
|
+
as methods), that is why callables like request_id_factory are passed
|
|
66
|
+
via the constructor parameters instead.
|
|
67
|
+
|
|
68
|
+
The get_request_id, log_request_start and add_response_request_id_header
|
|
69
|
+
parameters take precedence over the methods of the same name - when the
|
|
70
|
+
parameter is passed, the method (even one overridden in a subclass) is
|
|
71
|
+
not called.
|
|
72
|
+
|
|
73
|
+
request_id_middleware is a backward compatibility wrapper function
|
|
74
|
+
creating an instance of this class.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
__middleware_version__ = 1 # aiohttp 3 needs this; this is what @web.middleware is setting
|
|
78
|
+
|
|
79
|
+
# Default values - can be overridden when subclassing
|
|
80
|
+
# (plain values only - a function here would be bound as a method)
|
|
81
|
+
request_id_header_name: str = "X-Request-Id"
|
|
82
|
+
log_function_name: bool = True
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
request_id_factory: Callable[[], str] | None = None,
|
|
88
|
+
get_request_id: Callable[[web.Request], str | None] | None = None,
|
|
89
|
+
log_request_start: Callable[[web.Request, Handler], None] | None = None,
|
|
90
|
+
log_function_name: bool | None = None,
|
|
91
|
+
add_response_request_id_header: Callable[[web.StreamResponse, str], None] | None = None,
|
|
92
|
+
request_id_header_name: str | None = None,
|
|
93
|
+
no_fallback_request_id_key: bool = False,
|
|
94
|
+
):
|
|
95
|
+
# Set self.request_id_factory
|
|
96
|
+
if request_id_factory is None:
|
|
97
|
+
self.request_id_factory = random_request_id_factory
|
|
98
|
+
elif callable(request_id_factory):
|
|
99
|
+
self.request_id_factory = request_id_factory
|
|
100
|
+
else:
|
|
101
|
+
raise TypeError("request_id_factory must be a callable")
|
|
102
|
+
|
|
103
|
+
# Set self._get_request_id_override, used instead of the get_request_id method
|
|
104
|
+
if get_request_id is not None and not callable(get_request_id):
|
|
105
|
+
raise TypeError("get_request_id must be a callable")
|
|
106
|
+
self._get_request_id_override = get_request_id
|
|
107
|
+
|
|
108
|
+
# Set self._log_request_start_override, used instead of the
|
|
109
|
+
# log_request_start method (see before_request)
|
|
110
|
+
if log_request_start is not None and not callable(log_request_start):
|
|
111
|
+
raise TypeError("log_request_start must be a callable; pass noop to disable the message")
|
|
112
|
+
self._log_request_start_override = log_request_start
|
|
113
|
+
|
|
114
|
+
# Set self.log_function_name
|
|
115
|
+
if log_function_name is not None:
|
|
116
|
+
self.log_function_name = log_function_name
|
|
117
|
+
if not isinstance(self.log_function_name, bool):
|
|
118
|
+
raise TypeError("log_function_name must be a bool")
|
|
119
|
+
|
|
120
|
+
# Set self._add_response_request_id_header_override, used instead of the
|
|
121
|
+
# add_response_request_id_header method (see after_request)
|
|
122
|
+
if add_response_request_id_header is not None and not callable(add_response_request_id_header):
|
|
123
|
+
raise TypeError("add_response_request_id_header must be a callable; pass noop to disable the header")
|
|
124
|
+
self._add_response_request_id_header_override = add_response_request_id_header
|
|
125
|
+
|
|
126
|
+
# Set self.request_id_header_name
|
|
127
|
+
if request_id_header_name is not None:
|
|
128
|
+
self.request_id_header_name = request_id_header_name
|
|
129
|
+
if not isinstance(self.request_id_header_name, str):
|
|
130
|
+
raise TypeError("request_id_header_name must be a str")
|
|
131
|
+
|
|
132
|
+
self._fallback_request_id_key = None if no_fallback_request_id_key else FALLBACK_REQUEST_ID_KEY
|
|
133
|
+
|
|
134
|
+
self.sentry_make_scope = self.resolve_sentry_make_scope()
|
|
135
|
+
|
|
136
|
+
async def __call__(self, request: web.Request, handler: Handler) -> web.StreamResponse:
|
|
137
|
+
"""
|
|
138
|
+
The middleware entrypoint - process one request.
|
|
139
|
+
|
|
140
|
+
Obtains the request id from get_request_id (falling back to
|
|
141
|
+
request_id_factory when it returns None), sets the request_id
|
|
142
|
+
ContextVar for the whole duration of the request processing,
|
|
143
|
+
calls the handler between before_request and after_request,
|
|
144
|
+
and converts an unhandled exception from the handler to a 500
|
|
145
|
+
response (get_response_for_exception). A raised HTTPException
|
|
146
|
+
is re-raised for aiohttp to process - after after_request adds
|
|
147
|
+
the request id header to it.
|
|
148
|
+
"""
|
|
149
|
+
if self._get_request_id_override is not None:
|
|
150
|
+
req_id = self._get_request_id_override(request)
|
|
151
|
+
else:
|
|
152
|
+
req_id = self.get_request_id(request)
|
|
153
|
+
if req_id is None:
|
|
154
|
+
req_id = self.request_id_factory()
|
|
155
|
+
with ExitStack() as stack:
|
|
156
|
+
# Set request id context variable as a first thing
|
|
157
|
+
token = request_id_cv.set(req_id)
|
|
158
|
+
stack.callback(lambda: request_id_cv.reset(token))
|
|
159
|
+
|
|
160
|
+
await self.before_request(request, handler, req_id, stack)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
response = await handler(request)
|
|
164
|
+
except CancelledError as exc:
|
|
165
|
+
logger.info("(Cancelled)")
|
|
166
|
+
raise exc
|
|
167
|
+
except HTTPException as http_exc:
|
|
168
|
+
# HTTPException is also the response aiohttp sends to the
|
|
169
|
+
# client, so let after_request add the request id header
|
|
170
|
+
# to it before re-raising. It must be re-raised, not
|
|
171
|
+
# returned - a returned HTTPException makes aiohttp emit
|
|
172
|
+
# the "returning HTTPException object is deprecated
|
|
173
|
+
# (#2415)" DeprecationWarning.
|
|
174
|
+
await self.after_request(request, handler, http_exc, req_id, stack)
|
|
175
|
+
raise http_exc
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
# We are processing the 500 error right here, because if we
|
|
178
|
+
# let the web server process it, it would be outside of the
|
|
179
|
+
# request_id contextvar scope.
|
|
180
|
+
# (And also outside the sentry scope, if sentry is enabled.)
|
|
181
|
+
logger.exception("Error handling request: %r", exc)
|
|
182
|
+
response = self.get_response_for_exception(request, exc)
|
|
183
|
+
|
|
184
|
+
await self.after_request(request, handler, response, req_id, stack)
|
|
185
|
+
return response
|
|
186
|
+
|
|
187
|
+
def get_request_id(self, request: web.Request) -> str | None:
|
|
188
|
+
"""
|
|
189
|
+
Return the request id for the given request, or None to have one
|
|
190
|
+
generated with request_id_factory.
|
|
191
|
+
|
|
192
|
+
The default implementation returns None. Override this to e.g.
|
|
193
|
+
adopt a request id from an incoming header - but validate the
|
|
194
|
+
value, it is controlled by the client
|
|
195
|
+
(see examples/demo_customization_subclassing.py).
|
|
196
|
+
|
|
197
|
+
Not called when a get_request_id callable was passed to the
|
|
198
|
+
constructor - the callable is used instead (see __call__).
|
|
199
|
+
"""
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
async def before_request(self, request: web.Request, handler: Handler, req_id: str, stack: ExitStack) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Called before the handler: store the request id in the request,
|
|
205
|
+
set up the Sentry scope and log the request start message.
|
|
206
|
+
|
|
207
|
+
The stack parameter (a contextlib.ExitStack) can be used to register
|
|
208
|
+
cleanup that runs after the request is processed.
|
|
209
|
+
|
|
210
|
+
This is where the log_request_start constructor parameter is applied -
|
|
211
|
+
when overriding this method without calling super(), taking the
|
|
212
|
+
parameter into account is up to you.
|
|
213
|
+
"""
|
|
214
|
+
# Request keys come first so that the log_request_start hook can
|
|
215
|
+
# read request[REQUEST_ID_KEY], and so that a double-applied
|
|
216
|
+
# middleware raises RequestIdKeyAlreadySetError before producing
|
|
217
|
+
# any side effects (Sentry scope, log message).
|
|
218
|
+
self.set_request_keys(request, req_id)
|
|
219
|
+
# Sentry scope comes before the log message so that it is
|
|
220
|
+
# captured in the scope (as a breadcrumb).
|
|
221
|
+
self.setup_sentry_scope(req_id, stack)
|
|
222
|
+
if self._log_request_start_override is not None:
|
|
223
|
+
self._log_request_start_override(request, handler)
|
|
224
|
+
else:
|
|
225
|
+
self.log_request_start(request, handler)
|
|
226
|
+
|
|
227
|
+
def get_response_for_exception(self, request: web.Request, exc: Exception) -> web.StreamResponse:
|
|
228
|
+
"""
|
|
229
|
+
Create the 500 response for an unhandled exception from the handler.
|
|
230
|
+
|
|
231
|
+
Override this to customize the error response - the request
|
|
232
|
+
parameter allows e.g. content negotiation (JSON for API paths).
|
|
233
|
+
"""
|
|
234
|
+
response = web.Response(status=500, text="500 Internal Server Error\n")
|
|
235
|
+
response.force_close()
|
|
236
|
+
return response
|
|
237
|
+
|
|
238
|
+
async def after_request(
|
|
239
|
+
self,
|
|
240
|
+
request: web.Request,
|
|
241
|
+
handler: Handler,
|
|
242
|
+
response: web.StreamResponse,
|
|
243
|
+
req_id: str,
|
|
244
|
+
stack: ExitStack,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Called after the handler returns - or with a raised HTTPException
|
|
248
|
+
before it is re-raised, or with the 500 response an unhandled
|
|
249
|
+
exception was converted to. Adds the request id response header.
|
|
250
|
+
|
|
251
|
+
This is where the add_response_request_id_header constructor
|
|
252
|
+
parameter is applied - when overriding this method without calling
|
|
253
|
+
super(), taking the parameter into account is up to you.
|
|
254
|
+
"""
|
|
255
|
+
if self._add_response_request_id_header_override is not None:
|
|
256
|
+
self._add_response_request_id_header_override(response, req_id)
|
|
257
|
+
else:
|
|
258
|
+
self.add_response_request_id_header(response, req_id)
|
|
259
|
+
|
|
260
|
+
def log_request_start(self, request: web.Request, handler: Handler) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Log the "Processing GET / (...)" message at the start of the request.
|
|
263
|
+
|
|
264
|
+
Not called when a log_request_start callable was passed to the
|
|
265
|
+
constructor - the callable is used instead (see before_request).
|
|
266
|
+
"""
|
|
267
|
+
if self.log_function_name:
|
|
268
|
+
logger.info("Processing %s %s (%s)", request.method, request.path, self.get_function_name(handler))
|
|
269
|
+
else:
|
|
270
|
+
logger.info("Processing %s %s", request.method, request.path)
|
|
271
|
+
|
|
272
|
+
def set_request_keys(self, request: web.Request, req_id: str) -> None:
|
|
273
|
+
"""
|
|
274
|
+
Store the request id in the request - under REQUEST_ID_KEY and,
|
|
275
|
+
unless disabled (no_fallback_request_id_key=True), also under
|
|
276
|
+
the backward compatibility plain string key request['request_id'].
|
|
277
|
+
"""
|
|
278
|
+
if REQUEST_ID_KEY in request:
|
|
279
|
+
raise RequestIdKeyAlreadySetError(request[REQUEST_ID_KEY])
|
|
280
|
+
request[REQUEST_ID_KEY] = req_id
|
|
281
|
+
|
|
282
|
+
if self._fallback_request_id_key is not None:
|
|
283
|
+
with warnings.catch_warnings():
|
|
284
|
+
warnings.simplefilter("ignore", getattr(web, "NotAppKeyWarning", UserWarning))
|
|
285
|
+
|
|
286
|
+
if self._fallback_request_id_key in request:
|
|
287
|
+
raise RequestIdKeyAlreadySetError(request[self._fallback_request_id_key])
|
|
288
|
+
request[self._fallback_request_id_key] = req_id
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def resolve_sentry_make_scope() -> Callable[[], AbstractContextManager[Any]] | None:
|
|
292
|
+
"""
|
|
293
|
+
Find the function for creating a new Sentry scope, or return None
|
|
294
|
+
if sentry_sdk is not installed (or provides no such function).
|
|
295
|
+
|
|
296
|
+
For compatibility with sentry_sdk 1.x and 2.x:
|
|
297
|
+
push_scope is deprecated and will be removed, isolation_scope is its
|
|
298
|
+
recommended replacement for the request-response cycle.
|
|
299
|
+
"""
|
|
300
|
+
# Read sentry_sdk from the package at call time so that
|
|
301
|
+
# a replaced aiohttp_request_id_logging.sentry_sdk (e.g. monkeypatched
|
|
302
|
+
# in tests) is taken into account.
|
|
303
|
+
from . import sentry_sdk
|
|
304
|
+
|
|
305
|
+
if sentry_sdk is None:
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
return sentry_sdk.isolation_scope
|
|
309
|
+
except AttributeError:
|
|
310
|
+
pass
|
|
311
|
+
try:
|
|
312
|
+
return sentry_sdk.push_scope
|
|
313
|
+
except AttributeError:
|
|
314
|
+
warnings.warn(
|
|
315
|
+
"sentry_sdk does not contain isolation_scope or push_scope. "
|
|
316
|
+
"This is most likely due to a version change to >2.x, "
|
|
317
|
+
"please consult the Sentry documentation on how to fix this. "
|
|
318
|
+
"The `request_id` tag will not be pushed to Sentry.",
|
|
319
|
+
UserWarning,
|
|
320
|
+
)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
def setup_sentry_scope(self, req_id: str, stack: ExitStack) -> None:
|
|
324
|
+
"""
|
|
325
|
+
Create a new Sentry scope (entered into the given ExitStack)
|
|
326
|
+
and add the request_id tag to it.
|
|
327
|
+
|
|
328
|
+
Does nothing if sentry_sdk is not installed.
|
|
329
|
+
"""
|
|
330
|
+
if self.sentry_make_scope is not None:
|
|
331
|
+
scope = stack.enter_context(self.sentry_make_scope())
|
|
332
|
+
scope.set_tag("request_id", req_id)
|
|
333
|
+
|
|
334
|
+
def add_response_request_id_header(self, response: web.StreamResponse, req_id: str) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Add the request id response header (named by request_id_header_name).
|
|
337
|
+
|
|
338
|
+
The header is silently not added (a missing response request id
|
|
339
|
+
header is not a serious problem) when:
|
|
340
|
+
|
|
341
|
+
- the response already contains the header, for example the handler
|
|
342
|
+
echoes the request id of an upstream proxy,
|
|
343
|
+
- the response was already prepared (a streaming or WebSocket
|
|
344
|
+
handler called response.prepare()) - its headers were already
|
|
345
|
+
sent to the client and cannot be changed anymore; to have the
|
|
346
|
+
header on streaming responses, set it in the handler before
|
|
347
|
+
calling prepare().
|
|
348
|
+
|
|
349
|
+
Not called when an add_response_request_id_header callable was
|
|
350
|
+
passed to the constructor - the callable is used instead
|
|
351
|
+
(see after_request).
|
|
352
|
+
"""
|
|
353
|
+
try:
|
|
354
|
+
if response.prepared:
|
|
355
|
+
# Mutating response.headers now would succeed, but the change
|
|
356
|
+
# would never reach the client.
|
|
357
|
+
return
|
|
358
|
+
if self.request_id_header_name in response.headers:
|
|
359
|
+
return
|
|
360
|
+
response.headers[self.request_id_header_name] = req_id
|
|
361
|
+
except Exception as e:
|
|
362
|
+
# Let's consider this response header non critical
|
|
363
|
+
logger.debug("Could not set response.headers[%r]: %r", self.request_id_header_name, e)
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def get_function_name(f: Callable[..., Any]) -> str:
|
|
367
|
+
"""
|
|
368
|
+
Return a human-readable handler name for the request start message.
|
|
369
|
+
"""
|
|
370
|
+
try:
|
|
371
|
+
return f"{f.__module__}:{f.__name__}" # ty: ignore[unresolved-attribute]
|
|
372
|
+
except Exception:
|
|
373
|
+
return str(f)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def request_id_middleware(
|
|
377
|
+
*,
|
|
378
|
+
request_id_factory: Callable[[], str] | None = None,
|
|
379
|
+
log_function_name: bool = True,
|
|
380
|
+
log_request_start: bool = True,
|
|
381
|
+
) -> RequestIdMiddleware:
|
|
382
|
+
"""
|
|
383
|
+
Backward compatibility function for creating a RequestIdMiddleware instance.
|
|
384
|
+
|
|
385
|
+
Unlike the RequestIdMiddleware constructor, log_request_start is a bool
|
|
386
|
+
here - log_request_start=False translates to log_request_start=noop.
|
|
387
|
+
"""
|
|
388
|
+
if request_id_factory is not None and not callable(request_id_factory):
|
|
389
|
+
raise TypeError("request_id_factory must be a callable")
|
|
390
|
+
if not isinstance(log_function_name, bool):
|
|
391
|
+
raise TypeError("log_function_name must be a bool")
|
|
392
|
+
if not isinstance(log_request_start, bool):
|
|
393
|
+
raise TypeError("log_request_start must be a bool here; the RequestIdMiddleware constructor takes a callable")
|
|
394
|
+
return RequestIdMiddleware(
|
|
395
|
+
request_id_factory=request_id_factory,
|
|
396
|
+
log_function_name=log_function_name,
|
|
397
|
+
log_request_start=None if log_request_start else noop,
|
|
398
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from os import getpid
|
|
2
|
+
from secrets import token_urlsafe
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_SKIP_CHARS = "1lI2ZO0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def random_request_id_factory(length: int = 7) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Generate a random request id - a URL-safe string of the given length.
|
|
11
|
+
|
|
12
|
+
Visually ambiguous characters (1/l/I, 2/Z, O/0) and the URL-safe
|
|
13
|
+
punctuation (_, -) are never used in the generated id.
|
|
14
|
+
|
|
15
|
+
This is the default request id factory used in RequestIdMiddleware.
|
|
16
|
+
"""
|
|
17
|
+
while True:
|
|
18
|
+
req_id = token_urlsafe(length)[:length]
|
|
19
|
+
if "_" in req_id or "-" in req_id:
|
|
20
|
+
continue
|
|
21
|
+
if any(c in _SKIP_CHARS for c in req_id):
|
|
22
|
+
continue
|
|
23
|
+
return req_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SequentialRequestIdFactory:
|
|
27
|
+
"""
|
|
28
|
+
Alternative request id factory producing ids like "Wxyz0000", "Wxyz0001"...
|
|
29
|
+
- a random per-process prefix followed by a sequential number.
|
|
30
|
+
|
|
31
|
+
Usage: RequestIdMiddleware(request_id_factory=sequential_request_id_factory)
|
|
32
|
+
|
|
33
|
+
Caveat: if the request ids are ever exposed to clients (response header,
|
|
34
|
+
error page...), sequential ids reveal how many requests the server
|
|
35
|
+
processes and how many server processes there are. Note that
|
|
36
|
+
RequestIdMiddleware sends the request id to clients in the X-Request-Id
|
|
37
|
+
response header by default - pass add_response_request_id_header=noop
|
|
38
|
+
to disable that. If the exposure is a concern, use the default
|
|
39
|
+
random_request_id_factory instead.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
prefix_length: int = 4
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._pid: int | None = None
|
|
46
|
+
self._prefix: str | None = None
|
|
47
|
+
self._next_value: int = 0
|
|
48
|
+
|
|
49
|
+
def __call__(self) -> str:
|
|
50
|
+
pid = getpid()
|
|
51
|
+
if pid != self._pid:
|
|
52
|
+
self._prefix = self._generate_prefix()
|
|
53
|
+
self._next_value = 0
|
|
54
|
+
self._pid = pid
|
|
55
|
+
value = self._next_value
|
|
56
|
+
self._next_value += 1
|
|
57
|
+
return f"{self._prefix}{value:04}"
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def _generate_prefix(cls) -> str:
|
|
61
|
+
while True:
|
|
62
|
+
prefix = token_urlsafe(cls.prefix_length)[: cls.prefix_length]
|
|
63
|
+
if "_" in prefix or "-" in prefix:
|
|
64
|
+
continue
|
|
65
|
+
# Let's not have any numbers in the prefix so we keep more focus on the appended request number.
|
|
66
|
+
# This is just aesthetic thing.
|
|
67
|
+
if any(c.isdigit() for c in prefix):
|
|
68
|
+
continue
|
|
69
|
+
if any(c in _SKIP_CHARS for c in prefix):
|
|
70
|
+
continue
|
|
71
|
+
return prefix
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
sequential_request_id_factory = SequentialRequestIdFactory()
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiohttp-request-id-logging
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Setup proper request id logging for your Aiohttp app
|
|
5
|
+
Maintainer-email: Petr Messner <petr.messner@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/messa/aiohttp-request-id-logging
|
|
8
|
+
Project-URL: Bug Reports, https://github.com/messa/aiohttp-request-id-logging/issues
|
|
9
|
+
Project-URL: Source, https://github.com/messa/aiohttp-request-id-logging
|
|
10
|
+
Keywords: aiohttp,logging
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Framework :: aiohttp
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: aiohttp
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
aiohttp-request-id-logging
|
|
29
|
+
==========================
|
|
30
|
+
|
|
31
|
+
Motivation
|
|
32
|
+
----------
|
|
33
|
+
|
|
34
|
+
When logging from an async application (e.g. aiohttp web application),
|
|
35
|
+
log messages from different async tasks, http requests etc. are intertwined and you cannot surely tell which line was generated by what request.
|
|
36
|
+
For example:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
2020-01-15 15:35:37,501 INFO: Processing transfer id 1234
|
|
40
|
+
2020-01-15 15:35:37,976 INFO: Processing transfer id 5678
|
|
41
|
+
2020-01-15 15:35:38,201 ERROR: Oh no, something bad has happened! Cannot finish the transfer.
|
|
42
|
+
2020-01-15 15:35:38,504 INFO: 127.0.0.1 [15/Jan/2020:14:35:36 +0000] "POST / HTTP/1.1" 200 165 "-" "curl/7.68.0"
|
|
43
|
+
2020-01-15 15:35:38,982 INFO: 127.0.0.1 [15/Jan/2020:14:35:36 +0000] "POST / HTTP/1.1" 500 165 "-" "curl/7.68.0"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
So, which transfer has failed? The one with id 1234, or the one with id 5678?
|
|
47
|
+
|
|
48
|
+
Of course, ideally the error message should contain also the transfer id.
|
|
49
|
+
But it is not always this case, or you may want to inspect also other log messages that were logged by that specific task/request.
|
|
50
|
+
|
|
51
|
+
When you start to use this library in your aiohttp web application, this is how your log messages will look like:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
2020-01-15 15:58:47,238 INFO: [req:kbyLG8d] Processing GET / (__main__:hello)
|
|
55
|
+
2020-01-15 15:58:47,950 INFO: [req:xtMacpA] Processing GET / (__main__:hello)
|
|
56
|
+
2020-01-15 15:58:48,240 INFO: [req:kbyLG8d] Processing transfer id 1234
|
|
57
|
+
2020-01-15 15:58:48,953 INFO: [req:xtMacpA] Processing transfer id 5678
|
|
58
|
+
2020-01-15 15:58:49,182 ERROR: [req:xtMacpA] Oh no, something bad has happened! Cannot finish the transfer.
|
|
59
|
+
2020-01-15 15:58:49,242 INFO: [req:kbyLG8d] 127.0.0.1 "GET / HTTP/1.1" 200 165 "-" "curl/7.68.0"
|
|
60
|
+
2020-01-15 15:58:49,959 INFO: [req:xtMacpA] 127.0.0.1 "GET / HTTP/1.1" 500 165 "-" "curl/7.68.0"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
Installation
|
|
65
|
+
------------
|
|
66
|
+
|
|
67
|
+
```shell
|
|
68
|
+
$ uv add https://github.com/messa/aiohttp-request-id-logging/archive/v1.0.0.zip
|
|
69
|
+
# or
|
|
70
|
+
$ python3 -m pip install https://github.com/messa/aiohttp-request-id-logging/archive/v1.0.0.zip
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or add this line to `requirements.txt`:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
aiohttp-request-id-logging @ https://github.com/messa/aiohttp-request-id-logging/archive/v1.0.0.zip
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Usage
|
|
81
|
+
-----
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from aiohttp.web import Application, Response, RouteTableDef, run_app
|
|
85
|
+
from logging import DEBUG, basicConfig
|
|
86
|
+
from aiohttp_request_id_logging import (
|
|
87
|
+
setup_logging_request_id_prefix,
|
|
88
|
+
RequestIdMiddleware,
|
|
89
|
+
RequestIdAccessLogger,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
routes = RouteTableDef()
|
|
93
|
+
|
|
94
|
+
@routes.get('/')
|
|
95
|
+
async def hello(request):
|
|
96
|
+
return Response(text="Hello, world!\n")
|
|
97
|
+
|
|
98
|
+
basicConfig(
|
|
99
|
+
level=DEBUG,
|
|
100
|
+
format='%(asctime)s [%(threadName)s] %(name)-37s %(levelname)5s: %(requestIdPrefix)s%(message)s')
|
|
101
|
+
|
|
102
|
+
setup_logging_request_id_prefix()
|
|
103
|
+
|
|
104
|
+
app = Application(middlewares=[RequestIdMiddleware()])
|
|
105
|
+
app.router.add_routes(routes)
|
|
106
|
+
|
|
107
|
+
run_app(app, access_log_class=RequestIdAccessLogger)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For more complete example see [examples/demo.py](examples/demo.py).
|
|
111
|
+
|
|
112
|
+
Customization of the middleware behavior is demonstrated in
|
|
113
|
+
[`examples/demo_customization_injection.py`](examples/demo_customization_injection.py)
|
|
114
|
+
(constructor parameters) and
|
|
115
|
+
[`examples/demo_customization_subclassing.py`](examples/demo_customization_subclassing.py)
|
|
116
|
+
(subclassing).
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
How it works
|
|
120
|
+
------------
|
|
121
|
+
|
|
122
|
+
This library helps you to add request (correlation) id to the log messages in a few steps:
|
|
123
|
+
|
|
124
|
+
1. **`RequestIdMiddleware`:** Generate random `request_id` for each aiohttp request and
|
|
125
|
+
|
|
126
|
+
- store it in a ContextVar `aiohttp_request_id_logging.request_id`
|
|
127
|
+
- store it also in the request storage: `request[aiohttp_request_id_logging.REQUEST_ID_KEY]`
|
|
128
|
+
(and, by default, also under the plain string key `request['request_id']` for backward compatibility)
|
|
129
|
+
- return it to the client in the `X-Request-Id` response header
|
|
130
|
+
|
|
131
|
+
2. **`setup_logging_request_id_prefix()`:** Modify logging record factory so that the `request_id` is attached to every logging record created
|
|
132
|
+
|
|
133
|
+
- so you should modify your log format, for example `logging.basicConfig(format=... %(levelname)5s: %(requestIdPrefix)s%(message)s')`
|
|
134
|
+
|
|
135
|
+
3. Because the aiohttp access logging happens out of the middleware scope, the request id ContextVar would be already resetted. So **`RequestIdAccessLogger`** is provided that adds the request_id to the access log message.
|
|
136
|
+
|
|
137
|
+
4. Unhandled exceptions ("500 errors") are logged and converted to a 500 response directly by **`RequestIdMiddleware`** – if they were left to the standard aiohttp error handling, the traceback would be logged outside of the middleware scope, without the request id (see the [Reference](#requestidmiddleware) for details).
|
|
138
|
+
|
|
139
|
+
5. If you use **[Sentry](https://docs.sentry.io/platforms/python/aiohttp/)**, a `request_id` [tag](https://docs.sentry.io/enriching-error-data/context/?platform=python#tagging-events) is added when the request is processed.
|
|
140
|
+
|
|
141
|
+
Sentry integration will be active only if you have `sentry_sdk` installed.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
Reference
|
|
145
|
+
---------
|
|
146
|
+
|
|
147
|
+
All names are importable directly from the `aiohttp_request_id_logging` package.
|
|
148
|
+
|
|
149
|
+
### `RequestIdMiddleware`
|
|
150
|
+
|
|
151
|
+
The aiohttp middleware. Generates a request id for every request, stores it
|
|
152
|
+
in the `request_id` ContextVar and in the request, adds an `X-Request-Id`
|
|
153
|
+
response header, and (if Sentry is installed) creates a Sentry scope with
|
|
154
|
+
a `request_id` tag.
|
|
155
|
+
|
|
156
|
+
Exceptions raised from the handler are processed by the middleware too:
|
|
157
|
+
|
|
158
|
+
- an `HTTPException` (the aiohttp way of returning error responses from
|
|
159
|
+
anywhere in the handler) is re-raised for aiohttp to process, with the
|
|
160
|
+
request id response header added to it first
|
|
161
|
+
- any other unhandled exception is logged together with its traceback and
|
|
162
|
+
converted to a plain text `500 Internal Server Error` response (also
|
|
163
|
+
carrying the request id header) right in the middleware
|
|
164
|
+
|
|
165
|
+
The latter is deliberate: if the exception were left to the standard aiohttp
|
|
166
|
+
error handling, it would be logged outside of the middleware scope, where the
|
|
167
|
+
`request_id` ContextVar is already reset (and the Sentry scope with the
|
|
168
|
+
`request_id` tag closed) – so the traceback, usually the log message you need
|
|
169
|
+
to pair with a request the most, would be the one line without the request id.
|
|
170
|
+
Override `get_response_for_exception(request, exc)` to customize the error
|
|
171
|
+
response (a nicer error page, JSON for API paths...).
|
|
172
|
+
|
|
173
|
+
Constructor parameters (all keyword-only):
|
|
174
|
+
|
|
175
|
+
- `request_id_factory` – a zero-argument callable returning the request id string,
|
|
176
|
+
used when `get_request_id` returns `None` (which the default implementation
|
|
177
|
+
always does); default: `random_request_id_factory`
|
|
178
|
+
- `get_request_id` – a callable `(request)` returning the request id for the
|
|
179
|
+
given request, or `None` to have one generated with `request_id_factory`;
|
|
180
|
+
if you adopt an incoming header value here, validate it – it is controlled
|
|
181
|
+
by the client
|
|
182
|
+
- `log_request_start` – a callable `(request, handler)` that logs the request
|
|
183
|
+
start message, replacing the default `Processing GET / (...)` one;
|
|
184
|
+
pass `noop` to disable the message
|
|
185
|
+
- `log_function_name` – include the handler name in the default request start
|
|
186
|
+
message; default: `True`
|
|
187
|
+
- `add_response_request_id_header` – a callable `(response, req_id)` that adds
|
|
188
|
+
the request id header to the response, replacing the default behavior
|
|
189
|
+
(which keeps a header already set by the handler); pass `noop` to disable
|
|
190
|
+
the header
|
|
191
|
+
- `request_id_header_name` – name of the response header with the request id;
|
|
192
|
+
default: `X-Request-Id`
|
|
193
|
+
- `no_fallback_request_id_key` – if `True`, the request id is stored in the request
|
|
194
|
+
only under `REQUEST_ID_KEY`, not also under the backward compatibility plain
|
|
195
|
+
string key `request['request_id']`; default: `False`
|
|
196
|
+
|
|
197
|
+
The behavior can also be customized by subclassing – overriding the class
|
|
198
|
+
attributes (`request_id_header_name`, `log_function_name`) or the methods:
|
|
199
|
+
`get_request_id`, `before_request`, `after_request`,
|
|
200
|
+
`get_response_for_exception`, `log_request_start`, `set_request_keys`,
|
|
201
|
+
`setup_sentry_scope`, `add_response_request_id_header`, `get_function_name`.
|
|
202
|
+
|
|
203
|
+
Both approaches are demonstrated in
|
|
204
|
+
[`examples/demo_customization_injection.py`](examples/demo_customization_injection.py) and
|
|
205
|
+
[`examples/demo_customization_subclassing.py`](examples/demo_customization_subclassing.py).
|
|
206
|
+
|
|
207
|
+
Functions stored in class attributes are tricky (Python would bind them
|
|
208
|
+
as methods), that is why callables like `request_id_factory` are passed
|
|
209
|
+
via the constructor parameters instead.
|
|
210
|
+
|
|
211
|
+
The `get_request_id`, `log_request_start` and `add_response_request_id_header`
|
|
212
|
+
parameters take precedence over the methods of the same name – when the
|
|
213
|
+
parameter is passed, the method (even one overridden in a subclass) is not
|
|
214
|
+
called.
|
|
215
|
+
|
|
216
|
+
The middleware does not adopt a request id sent by the client – how (and
|
|
217
|
+
whether) to trust such a value depends on the deployment. If you want that,
|
|
218
|
+
pass (or override) `get_request_id(request)` and validate the incoming value
|
|
219
|
+
there; see
|
|
220
|
+
[`examples/demo_customization_subclassing.py`](examples/demo_customization_subclassing.py).
|
|
221
|
+
|
|
222
|
+
`request_id_middleware()` is a backward compatibility wrapper of this class;
|
|
223
|
+
unlike the constructor, its `log_request_start` parameter is a bool –
|
|
224
|
+
`log_request_start=False` translates to `log_request_start=noop`.
|
|
225
|
+
|
|
226
|
+
### `setup_logging_request_id_prefix()`
|
|
227
|
+
|
|
228
|
+
Wraps the logging record factory so that every log record gets two extra
|
|
229
|
+
attributes usable in the log format:
|
|
230
|
+
|
|
231
|
+
- `%(requestIdPrefix)s` – `"[req:kbyLG8d] "`, or an empty string outside of a request
|
|
232
|
+
- `%(request_id)s` – the raw request id, or `None`
|
|
233
|
+
|
|
234
|
+
The prefix can be customized with the `prefix_format` parameter
|
|
235
|
+
(default: `"[req:{request_id}] "`).
|
|
236
|
+
|
|
237
|
+
Safe to call multiple times (does the setup only once).
|
|
238
|
+
|
|
239
|
+
### `RequestIdAccessLogger`
|
|
240
|
+
|
|
241
|
+
Subclass of `aiohttp.web_log.AccessLogger` that sets the `request_id`
|
|
242
|
+
ContextVar while writing the access log line. Needed because aiohttp writes
|
|
243
|
+
the access log outside of the middleware scope. Pass it to
|
|
244
|
+
`run_app(app, access_log_class=RequestIdAccessLogger)`.
|
|
245
|
+
|
|
246
|
+
### `request_id`
|
|
247
|
+
|
|
248
|
+
`ContextVar` holding the request id of the currently processed request.
|
|
249
|
+
Read it with `request_id.get()` – it returns `None` outside of a request.
|
|
250
|
+
|
|
251
|
+
### `REQUEST_ID_KEY`
|
|
252
|
+
|
|
253
|
+
Key under which the request id is stored in the request:
|
|
254
|
+
`request[REQUEST_ID_KEY]`. It is a `web.RequestKey` instance on aiohttp
|
|
255
|
+
versions that support it, otherwise the plain string `'request_id'`.
|
|
256
|
+
(The plain string key `request['request_id']` is set as well by default,
|
|
257
|
+
for backward compatibility; pass `RequestIdMiddleware(no_fallback_request_id_key=True)`
|
|
258
|
+
to store the id only under `REQUEST_ID_KEY`.)
|
|
259
|
+
|
|
260
|
+
`FALLBACK_REQUEST_ID_KEY` is that backward compatibility plain string key –
|
|
261
|
+
`'request_id'`, or `None` on aiohttp versions without `web.RequestKey`
|
|
262
|
+
(there `REQUEST_ID_KEY` itself is the plain string, so there is no fallback).
|
|
263
|
+
|
|
264
|
+
### Request id factories
|
|
265
|
+
|
|
266
|
+
- `random_request_id_factory(length=7)` – the default; returns a random URL-safe
|
|
267
|
+
string of the given length, avoiding visually ambiguous characters
|
|
268
|
+
(`1`/`l`/`I`, `2`/`Z`, `O`/`0`)
|
|
269
|
+
(`generate_request_id` is its backward compatibility alias)
|
|
270
|
+
- `sequential_request_id_factory` / `SequentialRequestIdFactory` – alternative
|
|
271
|
+
factory producing ids like `Wxyz0000`, `Wxyz0001`… – a random per-process
|
|
272
|
+
prefix followed by a sequential number
|
|
273
|
+
|
|
274
|
+
Usage: `RequestIdMiddleware(request_id_factory=sequential_request_id_factory)`
|
|
275
|
+
|
|
276
|
+
Caveat: if the request ids are ever exposed to clients (response header,
|
|
277
|
+
error page…), sequential ids reveal how many requests the server processes
|
|
278
|
+
and how many server processes there are. Note that the middleware sends the
|
|
279
|
+
request id in the `X-Request-Id` response header by default – pass
|
|
280
|
+
`add_response_request_id_header=noop` to disable that. If the exposure is
|
|
281
|
+
a concern, stick with the default random factory.
|
|
282
|
+
|
|
283
|
+
### `noop`
|
|
284
|
+
|
|
285
|
+
A do-nothing function. Pass it as the `log_request_start` or
|
|
286
|
+
`add_response_request_id_header` constructor parameter to disable
|
|
287
|
+
the corresponding default behavior.
|
|
288
|
+
|
|
289
|
+
### `RequestIdKeyAlreadySetError`
|
|
290
|
+
|
|
291
|
+
Raised by the middleware when the request already contains a request id –
|
|
292
|
+
most likely the middleware is applied twice, or something else sets it too.
|
|
293
|
+
The existing id is available as the `existing_request_id` attribute.
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
Development
|
|
297
|
+
-----------
|
|
298
|
+
|
|
299
|
+
The project uses [uv](https://docs.astral.sh/uv/) for dependency and environment
|
|
300
|
+
management, [Ruff](https://docs.astral.sh/ruff/) for linting and formatting
|
|
301
|
+
and [ty](https://docs.astral.sh/ty/) for type checking.
|
|
302
|
+
|
|
303
|
+
```shell
|
|
304
|
+
$ make check # lint + ty check + tests (pytest)
|
|
305
|
+
$ make lint # ruff check + ruff format --check
|
|
306
|
+
$ make lint-fix # apply ruff fixes and formatting
|
|
307
|
+
$ make typecheck # ty check
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The Makefile targets use `uv run`, which automatically creates the `.venv`
|
|
311
|
+
virtualenv and installs the dependencies (including the `dev` dependency group)
|
|
312
|
+
on the first run.
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
Version changelog
|
|
316
|
+
-----------------
|
|
317
|
+
|
|
318
|
+
### 1.0.0 (2026-07-16)
|
|
319
|
+
|
|
320
|
+
- Added `aiohttp_request_id_logging.__version__`
|
|
321
|
+
- `RequestIdContextAccessLogger` was renamed to `RequestIdAccessLogger`;
|
|
322
|
+
the old name still works as a backward compatibility alias
|
|
323
|
+
- The middleware was refactored into a class `RequestIdMiddleware` so that it can be
|
|
324
|
+
subclassed and individual parts of the behavior customized by overriding class
|
|
325
|
+
attributes and methods (`before_request`, `after_request`,
|
|
326
|
+
`log_request_start`, `set_request_keys`, `setup_sentry_scope`,
|
|
327
|
+
`add_response_request_id_header`...)
|
|
328
|
+
- `request_id_middleware()` is kept as a backward compatibility wrapper function
|
|
329
|
+
- the deprecated-in-aiohttp-4 `@web.middleware` decorator is no longer used
|
|
330
|
+
- see the new examples
|
|
331
|
+
[`examples/demo_customization_injection.py`](examples/demo_customization_injection.py) and
|
|
332
|
+
[`examples/demo_customization_subclassing.py`](examples/demo_customization_subclassing.py)
|
|
333
|
+
- The middleware now returns the request id to the client in the `X-Request-Id`
|
|
334
|
+
response header (a header already set by the handler is not overwritten);
|
|
335
|
+
the header name can be changed with the `request_id_header_name`
|
|
336
|
+
parameter, or the header disabled completely with `add_response_request_id_header=noop`
|
|
337
|
+
- New hook `get_request_id(request)` (a constructor parameter or an overridable
|
|
338
|
+
method) – returns the request id for the given request, e.g. adopted from
|
|
339
|
+
an incoming header, or `None` to have a new id generated with
|
|
340
|
+
`request_id_factory` (if you adopt an incoming value, validate it – see
|
|
341
|
+
[`examples/demo_customization_subclassing.py`](examples/demo_customization_subclassing.py))
|
|
342
|
+
- The response request id header is silently skipped for responses that
|
|
343
|
+
were already prepared (streaming/WebSocket handlers)
|
|
344
|
+
- The request start message ("Processing ...") can be disabled
|
|
345
|
+
(`request_id_middleware(log_request_start=False)`,
|
|
346
|
+
`RequestIdMiddleware(log_request_start=noop)`) or replaced with a custom callable
|
|
347
|
+
(`RequestIdMiddleware(log_request_start=my_log_function)`)
|
|
348
|
+
- The middleware now raises `RequestIdKeyAlreadySetError` when the request
|
|
349
|
+
already contains a request id, for example when the middleware is applied twice
|
|
350
|
+
or something else also sets the request id
|
|
351
|
+
- New parameter `RequestIdMiddleware(no_fallback_request_id_key=True)` stores the
|
|
352
|
+
request id only under `REQUEST_ID_KEY`, skipping the backward compatibility
|
|
353
|
+
plain string key `request['request_id']` (the plain string key is now also
|
|
354
|
+
exported as `FALLBACK_REQUEST_ID_KEY`)
|
|
355
|
+
- Breaking: all `request_id_middleware()` parameters are now keyword-only; positional calls
|
|
356
|
+
like `request_id_middleware(my_factory)` raise `TypeError` – use
|
|
357
|
+
`request_id_middleware(request_id_factory=my_factory)` instead
|
|
358
|
+
- `HTTPException` raised from a handler is re-raised as before, but the request id
|
|
359
|
+
response header is now added to it first (the exception is also the response
|
|
360
|
+
aiohttp sends to the client)
|
|
361
|
+
- The 500 response for an unhandled exception from the handler can be customized
|
|
362
|
+
by overriding the `get_response_for_exception(request, exc)` method
|
|
363
|
+
- New parameter `setup_logging_request_id_prefix(prefix_format=...)` customizes the log
|
|
364
|
+
record prefix (default: `"[req:{request_id}] "`)
|
|
365
|
+
- The `request_id` ContextVar now has a default of `None`, so it can be read
|
|
366
|
+
with just `request_id.get()` – no more `LookupError` (or the `request_id.get(None)`
|
|
367
|
+
workaround) outside of a request
|
|
368
|
+
- Breaking: `random_request_id_factory()` has a new `length` parameter, replacing the
|
|
369
|
+
removed module-level variables `request_id_default_length` and
|
|
370
|
+
`default_request_id_factory`; the module-level function `get_function_name`
|
|
371
|
+
was also removed (it is now a `RequestIdMiddleware` static method)
|
|
372
|
+
- Generated request ids now avoid visually ambiguous characters
|
|
373
|
+
(`1`/`l`/`I`, `2`/`Z`, `O`/`0`)
|
|
374
|
+
- The package was split into more modules (`middleware.py`, `errors.py`, `context.py`,
|
|
375
|
+
`logging_setup.py`, `request_id_factories.py`); everything is
|
|
376
|
+
still importable directly from `aiohttp_request_id_logging`, which now also defines `__all__`
|
|
377
|
+
- Added type hints, together with the `py.typed` marker so that the types
|
|
378
|
+
are visible to type checkers (mypy, pyright) in projects using this library
|
|
379
|
+
- Added example [examples/demo_legacy.py](examples/demo_legacy.py) demonstrating
|
|
380
|
+
the backward compatible `request_id_middleware()` usage
|
|
381
|
+
- Tests: every example in [examples/](examples/) is now run and checked
|
|
382
|
+
(the request id in the response header, the log line prefixes, clean stderr);
|
|
383
|
+
added tests for the HTTPException and 500 error handling
|
|
384
|
+
- Development: the project is now managed with [uv](https://docs.astral.sh/uv/)
|
|
385
|
+
(`uv.lock`, dev dependencies in the `[dependency-groups]` table, which replaced
|
|
386
|
+
the `test` extra); linting was migrated from flake8 to
|
|
387
|
+
[Ruff](https://docs.astral.sh/ruff/) and the codebase was reformatted
|
|
388
|
+
with `ruff format` (see the new `make lint` and `make lint-fix` targets)
|
|
389
|
+
- Development: added type checking with [ty](https://docs.astral.sh/ty/)
|
|
390
|
+
(`make typecheck`, also run in CI); `sentry-sdk` was added to the dev
|
|
391
|
+
dependencies so that the Sentry integration is type checked too
|
|
392
|
+
- Packaging: modernized the pyproject metadata – SPDX license expression
|
|
393
|
+
(`license = "MIT"`), added the Development Status, Framework and
|
|
394
|
+
Typing :: Typed classifiers
|
|
395
|
+
- Github Actions: lint runs in a separate job; uv is used to install Python
|
|
396
|
+
and the dependencies; the test matrix also includes the latest aiohttp
|
|
397
|
+
release (unpinned) to catch regressions with new aiohttp versions early
|
|
398
|
+
|
|
399
|
+
### 0.0.8 (2026-07-14)
|
|
400
|
+
|
|
401
|
+
- Store the request id in the request under a `web.RequestKey` instance,
|
|
402
|
+
exported as `aiohttp_request_id_logging.REQUEST_ID_KEY` – fixes `NotAppKeyWarning`
|
|
403
|
+
emitted by aiohttp 3.13/3.14
|
|
404
|
+
- the plain string key `request['request_id']` is still set for backward compatibility
|
|
405
|
+
- Moved `demo.py` to [examples/](examples/)
|
|
406
|
+
- Fixed pyproject metadata
|
|
407
|
+
- Tests: run the demo in Python dev mode and assert clean stderr
|
|
408
|
+
(aiohttp-internal warnings are whitelisted)
|
|
409
|
+
- Github Actions: added Python 3.14, updated action versions
|
|
410
|
+
|
|
411
|
+
### 0.0.7 (2025-05-12)
|
|
412
|
+
|
|
413
|
+
- Support new scope handling (`isolation_scope`) in sentry_sdk 2.x
|
|
414
|
+
and removed an incorrect upgrade warning
|
|
415
|
+
([#6](https://github.com/messa/aiohttp-request-id-logging/pull/6), thanks @martinhanzik)
|
|
416
|
+
- Github Actions: added Python 3.13
|
|
417
|
+
|
|
418
|
+
### 0.0.6 (2024-06-02)
|
|
419
|
+
|
|
420
|
+
- Added `sequential_request_id_factory` as an alternative request id generator
|
|
421
|
+
- Improved `demo.py` and added a test running it
|
|
422
|
+
|
|
423
|
+
### 0.0.5 (2024-05-02)
|
|
424
|
+
|
|
425
|
+
- `RequestIdContextAccessLogger` no longer fails when the request id is not set,
|
|
426
|
+
for example when an error occurs in a middleware
|
|
427
|
+
- Github Actions: added Python 3.12
|
|
428
|
+
|
|
429
|
+
### 0.0.4 (2023-05-11)
|
|
430
|
+
|
|
431
|
+
- Fixed `request_id_default_length` handling: the generated request id is now
|
|
432
|
+
exactly that many characters long (and the default was changed from 5 to 7)
|
|
433
|
+
|
|
434
|
+
### 0.0.3 (2023-05-11)
|
|
435
|
+
|
|
436
|
+
- Added `req:` to the log record prefix: `[x12y3]` → `[req:x12y3]`
|
|
437
|
+
|
|
438
|
+
### 0.0.2 (2023-05-11)
|
|
439
|
+
|
|
440
|
+
- Migrated packaging from `setup.py` to `pyproject.toml`
|
|
441
|
+
- Added flake8 lint configuration
|
|
442
|
+
- Github Actions: added Python 3.10 and 3.11, added aiohttp version matrix
|
|
443
|
+
|
|
444
|
+
### 0.0.1 (2021-05-18)
|
|
445
|
+
|
|
446
|
+
- Initial release: `request_id_middleware()`, `setup_logging_request_id_prefix()`,
|
|
447
|
+
`RequestIdContextAccessLogger` and the Sentry `request_id` tag integration
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
Alternatives
|
|
451
|
+
------------
|
|
452
|
+
|
|
453
|
+
- [belvaio-request-id](https://pypi.org/project/belvaio-request-id/) – another
|
|
454
|
+
request id middleware + access logger for aiohttp; it also adopts the id
|
|
455
|
+
from the incoming `X-Request-Id` header and attaches it to log records via
|
|
456
|
+
a logging filter (instead of the log record factory used here); last
|
|
457
|
+
released in 2021 and its source repository is no longer available
|
|
458
|
+
- [asgi-correlation-id](https://github.com/snok/asgi-correlation-id) – the
|
|
459
|
+
same idea for the ASGI world (FastAPI, Starlette, Django...; aiohttp is not
|
|
460
|
+
ASGI, so it cannot be used here) – correlation id middleware, a logging
|
|
461
|
+
filter and a Sentry integration
|
|
462
|
+
- [django-guid](https://github.com/snok/django-guid) – correlation id
|
|
463
|
+
middleware and logging integration for Django
|
|
464
|
+
- [structlog contextvars](https://www.structlog.org/en/stable/contextvars.html) –
|
|
465
|
+
if you log via structlog, you can bind a request id to the context-local
|
|
466
|
+
context yourself (`structlog.contextvars.bind_contextvars(request_id=...)`)
|
|
467
|
+
in a small custom middleware
|
|
468
|
+
- [aiotask-context](https://github.com/Skyscanner/aiotask-context) – stores
|
|
469
|
+
contextual data (e.g. a request id) directly in the asyncio task and
|
|
470
|
+
includes an aiohttp request id logging example; predates `contextvars`,
|
|
471
|
+
its last release was in 2020 and the repository is now archived
|
|
472
|
+
- [OpenTelemetry Python](https://opentelemetry.io/docs/languages/python/) –
|
|
473
|
+
the heavyweight solution: full distributed tracing with an aiohttp server
|
|
474
|
+
instrumentation, and the logging instrumentation can optionally inject
|
|
475
|
+
`otelTraceID`/`otelSpanID` into log records (using the same log record
|
|
476
|
+
factory technique as this library)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
Links
|
|
480
|
+
-----
|
|
481
|
+
|
|
482
|
+
In general, you should be familiar with Aiohttp documentation related to production deployment:
|
|
483
|
+
|
|
484
|
+
- https://docs.aiohttp.org/en/stable/web_advanced.html#deploying-behind-a-proxy
|
|
485
|
+
- https://docs.aiohttp.org/en/stable/deployment.html
|
|
486
|
+
|
|
487
|
+
This is where this started: https://stackoverflow.com/a/58801740/196206
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
aiohttp_request_id_logging/__init__.py,sha256=WOSJkJZINkxmqgNTA56YvHWnMs8Q06EOVa6xvpzM9i4,1935
|
|
2
|
+
aiohttp_request_id_logging/context.py,sha256=FegKTEIb8uaSZu4CegXoK3OVXRxJmF9OlX0_FjDNgp0,737
|
|
3
|
+
aiohttp_request_id_logging/errors.py,sha256=g6TKVjc1KNZP5XovBlxsPt4RMMPGCqmTHyVzSt_NSwI,610
|
|
4
|
+
aiohttp_request_id_logging/logging_setup.py,sha256=wDzBLcXlfM1Pbp6Ubue_HlqhhrrvrxoUd_dEmlnD6ys,2420
|
|
5
|
+
aiohttp_request_id_logging/middleware.py,sha256=Hgu8CEl9qTFbEK2SQ2FLCkxt_JdeNCnvHsuzBeSV4Do,18115
|
|
6
|
+
aiohttp_request_id_logging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
aiohttp_request_id_logging/request_id_factories.py,sha256=XOTpPbLCS5KMrjNxyDb0_hcpYFz6CsTj9Efnxws8i5U,2487
|
|
8
|
+
aiohttp_request_id_logging-1.0.0.dist-info/licenses/LICENSE,sha256=_bbzd693JgJttwfk-gFGTwJiLY49xqJsRbLp36PBIyw,1069
|
|
9
|
+
aiohttp_request_id_logging-1.0.0.dist-info/METADATA,sha256=PHT-U3Lis2zqwFdH1Dh94oXLdlSll-1YVfOTf3nk-DE,23209
|
|
10
|
+
aiohttp_request_id_logging-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
aiohttp_request_id_logging-1.0.0.dist-info/top_level.txt,sha256=UCvVq_jfTvfEg9Z46kyiqmuQATFsTTTP2QEg_M8srLA,27
|
|
12
|
+
aiohttp_request_id_logging-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Petr Messner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp_request_id_logging
|