Skip to content

Commit 91aa2c1

Browse files
committed
Add request id middleware
1 parent 0cbb892 commit 91aa2c1

File tree

6 files changed

+293
-2
lines changed

6 files changed

+293
-2
lines changed

README.md

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,20 @@ MIDDLEWARE = [
6060
]
6161
```
6262

63-
This will start storing the request logs using the default `STORAGE_CLASS`, which in fact just uses Python logger named `requestlogs`. Now you can, for example, redirect these logs to a file with the following `LOGGING` configuration:
63+
Set `'requestlogs.views.exception_handler'` as rest_framework's exception handler
64+
(this will make sure requestlog entry has all possible data available about the
65+
request in case of a 500 error):
66+
67+
```python
68+
REST_FRAMEWORK={
69+
...
70+
'EXCEPTION_HANDLER': 'requestlogs.views.exception_handler',
71+
}
72+
```
73+
74+
The middleware is now ready to start storing requestlog entries using the default
75+
`STORAGE_CLASS`, which in fact just uses Python logger named `requestlogs`. Now you can,
76+
for example, redirect these logs to a file with the following `LOGGING` configuration:
6477

6578
```python
6679
LOGGING = {
@@ -77,6 +90,7 @@ LOGGING = {
7790
'requestlogs': {
7891
'handlers': ['requestlogs_to_file'],
7992
'level': 'INFO',
93+
'propagate': False,
8094
},
8195
},
8296
}
@@ -103,3 +117,95 @@ REQUESTLOGS = {
103117
- List of keys in request/response data which will be replaced with `'***'` in the stored entry.
104118
- **ATTRIBUTE_NAME**
105119
- django-requestlogs internally attaches the entry object to the Django request object, and uses this attribute name. Override if it causes collisions.
120+
121+
122+
# Logging with Request ID
123+
124+
django-requestlogs also contains a middleware and logging helpers to associate a unique
125+
identifier (uuid) for each request (implemented using `threading.local()`).
126+
The request id can be added to the standard logging messages (Django application logs)
127+
by specifying a custom formatter and using the provided logging filter.
128+
The request id can be stored to requestlog entries as well.
129+
The middleware to enable the request id logging can be used separately from the
130+
core requestlogs middleware.
131+
132+
The feature is enabled by adding `requestlogs.middleware.RequestLogsMiddleware`
133+
to the `MIDDLEWARE` setting:
134+
135+
```python
136+
MIDDLEWARE = [
137+
...
138+
'requestlogs.middleware.RequestLogsMiddleware',
139+
'requestlogs.middleware.RequestIdMiddleware',
140+
]
141+
```
142+
143+
Once installed, the application logs should start showing messages with a format such as
144+
the following:
145+
146+
```
147+
2019-07-18 11:56:07,261 INFO 954fb004fb404751a2fa33326101442c urls:31 Handling GET request
148+
2019-07-18 11:56:07,262 DEBUG 954fb004fb404751a2fa33326101442c urls:32 No parameters given
149+
2019-07-18 11:56:07,262 INFO 954fb004fb404751a2fa33326101442c urls:33 All good
150+
```
151+
152+
The middleware has some configuration possiblities:
153+
154+
155+
```python
156+
REQUESTLOGS = {
157+
...
158+
'REQUEST_ID_HTTP_HEADER': 'X_DJANGO_REQUEST_ID',
159+
'REQUEST_ID_ATTRIBUTE_NAME': 'request_id',
160+
}
161+
```
162+
- **REQUEST_ID_HTTP_HEADER**
163+
- If set, the value of this request header is used as request id (instead of it being
164+
randomly generated). Must be a valid uuid.
165+
- **REQUEST_ID_ATTRIBUTE_NAME**
166+
- The attribute name which is used internally to attach request id to
167+
`threading.locals()`. Override if it causes collisions.
168+
169+
To add the request id to logging messages of your Django application, use the provided
170+
logging filter and include `request_id` to the log formatter.
171+
Here is the complete logging configuration:
172+
173+
```python
174+
LOGGING = {
175+
'version': 1,
176+
'disable_existing_loggers': False,
177+
'handlers': {
178+
'requestlogs_to_file': {
179+
'level': 'INFO',
180+
'class': 'logging.FileHandler',
181+
'filename': '/tmp/requestlogs.log',
182+
},
183+
'root': {
184+
'class': 'logging.StreamHandler',
185+
'filters': ['request_id_context'],
186+
'formatter': 'verbose',
187+
},
188+
},
189+
'loggers': {
190+
'': {
191+
'handlers': ['root'],
192+
'level': 'DEBUG',
193+
},
194+
'requestlogs': {
195+
'handlers': ['requestlogs_to_file'],
196+
'level': 'INFO',
197+
'propagate': False,
198+
},
199+
},
200+
'filters': {
201+
'request_id_context': {
202+
'()': 'requestlogs.logging.RequestIdContext',
203+
},
204+
},
205+
'formatters': {
206+
'verbose': {
207+
'format': '%(asctime)s %(levelname)s %(request_id)s %(module)s:%(lineno)s %(message)s'
208+
},
209+
},
210+
}
211+
```

requestlogs/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
'ENTRY_CLASS': 'requestlogs.entries.RequestLogEntry',
99
'STORAGE_CLASS': 'requestlogs.storages.LoggingStorage',
1010
'SECRETS': ['password', 'token'],
11+
'REQUEST_ID_ATTRIBUTE_NAME': 'request_id',
12+
'REQUEST_ID_HTTP_HEADER': None,
1113
}
1214

1315

requestlogs/entries.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from rest_framework.request import Request
66

77
from .base import SETTINGS
8+
from .logging import get_request_id
89
from .utils import remove_secrets, get_client_ip
910

1011

@@ -28,6 +29,10 @@ def query_params(self):
2829
def full_path(self):
2930
return self.request.get_full_path()
3031

32+
@property
33+
def request_id(self):
34+
return get_request_id()
35+
3136

3237
class DRFRequestHandler(RequestHandler):
3338
@property

requestlogs/logging.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import logging
2+
import uuid
3+
import threading
4+
5+
from .base import SETTINGS
6+
7+
local = threading.local()
8+
9+
10+
def get_request_id():
11+
return getattr(local, SETTINGS['REQUEST_ID_ATTRIBUTE_NAME'], '')
12+
13+
14+
def set_request_id(_uuid=None):
15+
_uuid = _uuid or uuid.uuid4().hex
16+
setattr(local, SETTINGS['REQUEST_ID_ATTRIBUTE_NAME'], _uuid)
17+
return _uuid
18+
19+
20+
def validate_uuid(_uuid):
21+
try:
22+
val = uuid.UUID(_uuid, version=4)
23+
except (ValueError, TypeError):
24+
return None
25+
26+
return _uuid if val.hex == _uuid else None
27+
28+
29+
class RequestIdContext(logging.Filter):
30+
def filter(self, record):
31+
setattr(record, SETTINGS['REQUEST_ID_ATTRIBUTE_NAME'],
32+
get_request_id())
33+
return True

requestlogs/middleware.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from .base import SETTINGS
2+
from .logging import set_request_id, validate_uuid
13
from . import get_requestlog_entry
24

35

@@ -14,3 +16,13 @@ def __call__(self, request):
1416
response = self.get_response(request)
1517
get_requestlog_entry(request).finalize(response)
1618
return response
19+
20+
21+
class RequestIdMiddleware(object):
22+
def __init__(self, get_response):
23+
self.get_response = get_response
24+
25+
def __call__(self, request):
26+
reuse_request_id = request.META.get(SETTINGS['REQUEST_ID_HTTP_HEADER'])
27+
set_request_id(validate_uuid(reuse_request_id))
28+
return self.get_response(request)

tests/test_views.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from unittest.mock import patch
1+
import io
2+
import logging
3+
from unittest.mock import patch, Mock
24

35
import django
46
from django.contrib.auth import get_user_model
@@ -17,6 +19,7 @@
1719
from rest_framework.views import APIView
1820

1921
from requestlogs import get_requestlog_entry
22+
from requestlogs.logging import RequestIdContext
2023
from requestlogs.storages import BaseEntrySerializer
2124

2225

@@ -52,6 +55,11 @@ def retrieve(self, request):
5255
def create(self, request):
5356
return Response({})
5457

58+
def w_logging(self, request):
59+
logger = logging.getLogger('request_id_test')
60+
logger.info('GET with logging ({})'.format(request.GET.get('q')))
61+
return Response({})
62+
5563

5664
class SimpleAuth(BaseAuthentication):
5765
def authenticate(self, request):
@@ -100,6 +108,7 @@ def api_view_function(request):
100108
url(r'^viewset/1/?$', ViewSet.as_view({'get': 'retrieve'})),
101109
url(r'^func/?$', api_view_function),
102110
url(r'^error/?$', ServerErrorView.as_view()),
111+
url(r'^logging/?$', ViewSet.as_view({'get': 'w_logging'})),
103112
]
104113

105114

@@ -389,3 +398,127 @@ def test_500_response_without_custom_exception_handler(self):
389398
KeyError, self.client.post, '/error', {'pay': 'load'})
390399
self.expected['request']['data'] = None
391400
self.assert_stored(mocked_store, self.expected)
401+
402+
403+
class RequestIdStorage(TestStorage):
404+
class serializer_class(serializers.Serializer):
405+
request_id = serializers.CharField(source='request.request_id')
406+
407+
408+
class LoggingMixin(object):
409+
def _setup_logging(self):
410+
log_format = '{levelname} {request_id} {message}'
411+
stream = io.StringIO('')
412+
handler = logging.StreamHandler(stream)
413+
handler.setFormatter(logging.Formatter(log_format, style='{'))
414+
logger = logging.getLogger('request_id_test')
415+
logger.setLevel(logging.INFO)
416+
logger.addHandler(handler)
417+
logger.addFilter(RequestIdContext())
418+
self._log_stream = stream
419+
420+
def _assert_logged_lines(self, lines):
421+
self._log_stream.seek(0)
422+
raw = self._log_stream.read()
423+
assert raw.endswith('\n')
424+
logged_lines = [i for i in raw.split('\n')][:-1]
425+
assert logged_lines == lines
426+
427+
428+
@override_settings(
429+
ROOT_URLCONF=__name__,
430+
REQUESTLOGS={'STORAGE_CLASS': 'tests.test_views.RequestIdStorage'},
431+
)
432+
@modify_settings(MIDDLEWARE={
433+
'append': [
434+
'requestlogs.middleware.RequestLogsMiddleware',
435+
'requestlogs.middleware.RequestIdMiddleware',
436+
],
437+
})
438+
class TestRequestIdMiddleware(LoggingMixin, APITestCase):
439+
def test_request_id_generated(self):
440+
with patch('tests.test_views.RequestIdStorage.do_store') \
441+
as mocked_store, patch('uuid.uuid4') as mocked_uuid:
442+
mocked_uuid.side_effect = [Mock(hex='12345dcba')]
443+
response = self.client.get('/')
444+
assert mocked_store.call_args[0][0] == {'request_id': '12345dcba'}
445+
446+
def test_python_logging_with_request_id(self):
447+
# First build logging setup which outputs entries with request_id.
448+
# We cannot use `self.assertLogs`, because it uses the default
449+
# formatter, and so request_id wouldn't be seen in log output.
450+
self._setup_logging()
451+
452+
# Now that logging is set up to capture formatted log entries into the
453+
# `stream`, we can do the request and finally check the logged result.
454+
with patch('uuid.uuid4') as mocked_uuid:
455+
mocked_uuid.side_effect = [Mock(hex='12345dcba')]
456+
self.client.get('/logging?q=yes')
457+
458+
self._assert_logged_lines(['INFO 12345dcba GET with logging (yes)'])
459+
460+
def test_python_logging_and_requestlogs_entry(self):
461+
self._setup_logging()
462+
463+
with patch('tests.test_views.RequestIdStorage.do_store') \
464+
as mocked_store, patch('uuid.uuid4') as mocked_uuid, \
465+
patch('uuid.uuid4') as mocked_uuid:
466+
mocked_uuid.side_effect = [
467+
Mock(hex='12345dcba'), Mock(hex='ffc999123')]
468+
self.client.get('/logging?q=1')
469+
self.client.get('/logging?q=2')
470+
471+
call_args1, call_args2 = mocked_store.call_args_list
472+
assert call_args1[0][0] == {'request_id': '12345dcba'}
473+
assert call_args2[0][0] == {'request_id': 'ffc999123'}
474+
475+
self._assert_logged_lines([
476+
'INFO 12345dcba GET with logging (1)',
477+
'INFO ffc999123 GET with logging (2)',
478+
])
479+
480+
481+
@override_settings(
482+
ROOT_URLCONF=__name__,
483+
REQUESTLOGS={
484+
'STORAGE_CLASS': 'tests.test_views.RequestIdStorage',
485+
'REQUEST_ID_HTTP_HEADER': 'X_DJANGO_REQUEST_ID',
486+
},
487+
)
488+
@modify_settings(MIDDLEWARE={
489+
'append': [
490+
'requestlogs.middleware.RequestLogsMiddleware',
491+
'requestlogs.middleware.RequestIdMiddleware',
492+
],
493+
})
494+
class TestReuseRequestId(LoggingMixin, APITestCase):
495+
def test_reuse_request_id(self):
496+
self._setup_logging()
497+
498+
uuid = '6359abe9f7d849e09a324791c6a6c976'
499+
self.client.credentials(X_DJANGO_REQUEST_ID=uuid)
500+
501+
with patch('tests.test_views.RequestIdStorage.do_store') \
502+
as mocked_store, patch('uuid.uuid4') as mocked_uuid, \
503+
patch('uuid.uuid4') as mocked_uuid:
504+
mocked_uuid.side_effect = []
505+
response = self.client.get('/logging?q=p')
506+
assert mocked_store.call_args[0][0] == {'request_id': uuid}
507+
508+
self._assert_logged_lines([
509+
'INFO 6359abe9f7d849e09a324791c6a6c976 GET with logging (p)'])
510+
511+
def test_bad_request_id(self):
512+
self.client.credentials(X_DJANGO_REQUEST_ID='BAD')
513+
self._test_request_id_generated()
514+
515+
def test_no_request_id_present(self):
516+
self._test_request_id_generated()
517+
518+
def _test_request_id_generated(self):
519+
with patch('tests.test_views.RequestIdStorage.do_store') \
520+
as mocked_store, patch('uuid.uuid4') as mocked_uuid, \
521+
patch('uuid.uuid4') as mocked_uuid:
522+
mocked_uuid.side_effect = [Mock(hex='12345dcba')]
523+
response = self.client.get('/')
524+
assert mocked_store.call_args[0][0] == {'request_id': '12345dcba'}

0 commit comments

Comments
 (0)