Skip to content

Commit ee99aea

Browse files
committed
PB-511: Django request context in logs
Middleware and filter that can be combined to add django request fields to all logs within the scope of the request. Middleware adds request object to a thread local variable. Filter adds the request from thread to log record. Existing json filter can be used to decide which request fields should be added to the log.
1 parent 1043d2f commit ee99aea

File tree

10 files changed

+372
-1
lines changed

10 files changed

+372
-1
lines changed

.pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ function-naming-style=snake_case
276276
good-names=i,
277277
j,
278278
k,
279+
tc,
279280
ex,
280281
fd,
281282
Run,

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ All features can be fully configured from the configuration file.
4444
- [Usage](#usage)
4545
- [Django Request Filter Constructor](#django-request-filter-constructor)
4646
- [Django Request Config Example](#django-request-config-example)
47+
- [Django add request to log records](#django-add-request-to-log-records)
48+
- [Usage \& Configuration](#usage--configuration)
4749
- [Filter out LogRecord attributes based on their types](#filter-out-logrecord-attributes-based-on-their-types)
4850
- [Attribute Type Filter Constructor](#attribute-type-filter-constructor)
4951
- [Attribute Type Filter Config Example](#attribute-type-filter-config-example)
@@ -507,6 +509,65 @@ filters:
507509

508510
**NOTE**: `JsonDjangoRequest` only support the special key `'()'` factory in the configuration file (it doesn't work with the normal `'class'` key).
509511

512+
## Django add request to log records
513+
514+
Combine the use of the middleware `AddToThreadContextMiddleware` with the filters `AddThreadContextFilter` and `JsonDjangoRequest` to add request context to each log entry.
515+
516+
### Usage & Configuration
517+
518+
Add `AddToThreadContextMiddleware` to the django `settings.MIDDLEWARE` list. This will store the request value in a thread local variable.
519+
520+
For example:
521+
522+
```python
523+
MIDDLEWARE = (
524+
...,
525+
'logging_utilities.django_middlewares.add_request_context.AddToThreadContextMiddleware',
526+
...,
527+
)
528+
```
529+
530+
Configure the logging filter `AddThreadContextFilter` to add the request from the thread variable to the log record. the middleware `AddToThreadContextMiddleware` will add the request to the variable name `request`, so make sure the context_key has this value.
531+
532+
```yaml
533+
filters:
534+
add_request:
535+
(): logging_utilities.filters.add_thread_context_filter.AddThreadContextFilter
536+
contexts:
537+
- logger_key: http_request
538+
context_key: request
539+
```
540+
541+
| Parameter | Type | Default | Description |
542+
|------------|------|---------|------------------------------------------------|
543+
| `contexts` | list | empty | List of values to add to the log record. Dictionary must contain value for 'context_key' to read value from thread local variable. Dictionary must also contain 'logger_key' to set the value on the log record. |
544+
545+
Configure the logging filter `JsonDjangoRequest` to add request fields to the json log output.
546+
547+
For example:
548+
549+
```yaml
550+
filters:
551+
request_fields:
552+
(): logging_utilities.filters.django_request.JsonDjangoRequest
553+
include_keys:
554+
- http_request.path
555+
- http_request.method
556+
```
557+
558+
Make sure to add the filters in the correct order, for example:
559+
560+
```yaml
561+
handlers:
562+
console:
563+
formatter: json
564+
filters:
565+
# These filters modify the record in-place, and as the record is passed serially to each handler
566+
- add_request
567+
- request_fields
568+
569+
```
570+
510571
## Filter out LogRecord attributes based on their types
511572

512573
If different libraries or different parts of your code log different object types under the same
@@ -1305,3 +1366,5 @@ From version 1.x.x to version 2.x.x there is the following breaking change:
13051366
## Credits
13061367

13071368
The JSON Formatter implementation has been inspired by [MyColorfulDays/jsonformatter](https://github.com/MyColorfulDays/jsonformatter)
1369+
1370+
The Request Var middleware has been inspired by [kindlycat/django-request-vars](https://github.com/kindlycat/django-request-vars)

logging_utilities/django_middlewares/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from logging_utilities.thread_context import thread_context
2+
3+
4+
class AddToThreadContextMiddleware(object):
5+
"""Django middleware that stores request to thread local variable.
6+
"""
7+
8+
def __init__(self, get_response):
9+
self.get_response = get_response
10+
11+
def __call__(self, request):
12+
setattr(thread_context, 'request', request)
13+
response = self.get_response(request)
14+
setattr(thread_context, 'request', None)
15+
return response
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import logging
2+
from logging import LogRecord
3+
from typing import List
4+
5+
from logging_utilities.thread_context import thread_context
6+
7+
8+
class AddThreadContextFilter(logging.Filter):
9+
"""Add local thread attributes to the log record.
10+
"""
11+
12+
def __init__(self, contexts: List[dict] = None) -> None:
13+
"""Initialize the filter
14+
15+
Args:
16+
contexts (List[dict], optional):
17+
List of values to add to the log record. Dictionary must contain value for
18+
'context_key' to read value from thread local variable. Dictionary must also contain
19+
'logger_key' to set the value on the log record.
20+
"""
21+
self.contexts: List[dict] = [] if contexts is None else contexts
22+
super().__init__()
23+
24+
def filter(self, record: LogRecord) -> bool:
25+
for ctx in self.contexts:
26+
if getattr(thread_context, ctx['context_key'], None) is not None:
27+
setattr(record, ctx['logger_key'], getattr(thread_context, ctx['context_key']))
28+
return True
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from threading import local
2+
3+
4+
class ThreadContext(local):
5+
pass
6+
7+
8+
thread_context = ThreadContext()

tests/test_add_request_context.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import unittest
2+
3+
from django.conf import settings
4+
from django.test import RequestFactory
5+
6+
from logging_utilities.django_middlewares.add_request_context import \
7+
AddToThreadContextMiddleware
8+
from logging_utilities.thread_context import thread_context
9+
10+
if not settings.configured:
11+
settings.configure()
12+
13+
14+
class AddToThreadContextMiddlewareTest(unittest.TestCase):
15+
16+
def setUp(self) -> None:
17+
self.factory = RequestFactory()
18+
19+
def test_add_request(self):
20+
21+
def test_handler(request):
22+
r_from_var = getattr(thread_context, 'request', None)
23+
self.assertEqual(request, r_from_var)
24+
25+
request = self.factory.get("/some_path?test=some_value")
26+
middleware = AddToThreadContextMiddleware(test_handler)
27+
middleware(request)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import json
2+
import logging
3+
import sys
4+
import unittest
5+
from collections import OrderedDict
6+
7+
from django.conf import settings
8+
from django.test import RequestFactory
9+
10+
from logging_utilities.filters.add_thread_context_filter import \
11+
AddThreadContextFilter
12+
from logging_utilities.formatters.json_formatter import JsonFormatter
13+
from logging_utilities.thread_context import thread_context
14+
15+
if not settings.configured:
16+
settings.configure()
17+
18+
# From python3.7, dict is ordered
19+
if sys.version_info.major >= 3 and sys.version_info.minor >= 7:
20+
dictionary = dict
21+
else:
22+
dictionary = OrderedDict
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class AddThreadContextFilterTest(unittest.TestCase):
28+
29+
def setUp(self) -> None:
30+
self.factory = RequestFactory()
31+
32+
@classmethod
33+
def _configure_json_filter(cls, _logger):
34+
_logger.setLevel(logging.DEBUG)
35+
for handler in _logger.handlers:
36+
handler.setFormatter(JsonFormatter(add_always_extra=True))
37+
38+
def test_add_thread_context_no_request(self):
39+
with self.assertLogs('test_logger', level=logging.DEBUG) as ctx:
40+
test_logger = logging.getLogger("test_logger")
41+
self._configure_json_filter(test_logger)
42+
test_logger.addFilter(
43+
AddThreadContextFilter(
44+
contexts=[{
45+
'logger_key': 'http_request', 'context_key': 'request'
46+
}]
47+
)
48+
)
49+
test_logger.debug("some message")
50+
51+
message1 = json.loads(ctx.output[0], object_pairs_hook=dictionary)
52+
self.assertDictEqual(
53+
message1,
54+
dictionary([("levelname", "DEBUG"), ("name", "test_logger"),
55+
("message", "some message")])
56+
)
57+
58+
def test_add_thread_context(self):
59+
test_cases = [
60+
{
61+
'logger_name': 'test_1',
62+
'var_key': 'request',
63+
'var_val': "some value",
64+
'attr_name': 'http_request',
65+
'expect_value': "some value",
66+
'log_message': 'a log message has appeared',
67+
},
68+
{
69+
'logger_name': 'test_2',
70+
'var_key': 'request',
71+
'var_val': self.factory.get("/some_path"),
72+
'attr_name': 'request',
73+
'expect_value': "<WSGIRequest: GET '/some_path'>",
74+
'log_message': 'another log message has appeared',
75+
},
76+
]
77+
78+
for tc in test_cases:
79+
with self.assertLogs(tc['logger_name'], level=logging.DEBUG) as ctx:
80+
test_logger = logging.getLogger(tc['logger_name'])
81+
setattr(thread_context, tc['var_key'], tc['var_val'])
82+
self._configure_json_filter(test_logger)
83+
test_logger.addFilter(
84+
AddThreadContextFilter(
85+
contexts=[{
86+
'logger_key': tc['attr_name'], 'context_key': tc['var_key']
87+
}]
88+
)
89+
)
90+
91+
test_logger.debug(tc['log_message'])
92+
setattr(thread_context, tc['var_key'], None)
93+
94+
message1 = json.loads(ctx.output[0], object_pairs_hook=dictionary)
95+
self.assertDictEqual(
96+
message1,
97+
dictionary([("levelname", "DEBUG"), ("name", tc['logger_name']),
98+
("message", tc['log_message']), (tc['attr_name'], tc['expect_value'])])
99+
)

tests/test_django_attribute.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
else:
1717
dictionary = OrderedDict
1818

19-
settings.configure()
19+
if not settings.configured:
20+
settings.configure()
2021

2122
logger = logging.getLogger(__name__)
2223

0 commit comments

Comments
 (0)