Skip to content

Commit 2465f18

Browse files
authored
Merge pull request #62 from geoadmin/feat-PB-511-django-request-log
PB-511: Django request context in logs - #minor
2 parents 1043d2f + 611bc60 commit 2465f18

File tree

10 files changed

+401
-1
lines changed

10 files changed

+401
-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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ All features can be fully configured from the configuration file.
5555
- [Logger Level Filter](#logger-level-filter)
5656
- [Logger Level Filter Constructor](#logger-level-filter-constructor)
5757
- [Logger Level Filter Config Example](#logger-level-filter-config-example)
58+
- [Django middleware request context](#django-middleware-request-context)
59+
- [Log thread context](#log-thread-context)
5860
- [Basic Usage](#basic-usage)
5961
- [Case 1. Simple JSON Output](#case-1-simple-json-output)
6062
- [Case 2. JSON Output Configured within Python Code](#case-2-json-output-configured-within-python-code)
@@ -64,6 +66,7 @@ All features can be fully configured from the configuration file.
6466
- [Case 6. Add parts of Django Request to JSON Output](#case-6-add-parts-of-django-request-to-json-output)
6567
- [Case 7. Add all Log Extra as Dictionary to the Standard Formatter (including Django log extra)](#case-7-add-all-log-extra-as-dictionary-to-the-standard-formatter-including-django-log-extra)
6668
- [Case 8. Add Specific Log Extra to the Standard Formatter](#case-8-add-specific-log-extra-to-the-standard-formatter)
69+
- [Case 9. Django add request info to all log records](#case-9-django-add-request-info-to-all-log-records)
6770
- [Breaking Changes](#breaking-changes)
6871
- [Version 4.x.x Breaking Changes](#version-4xx-breaking-changes)
6972
- [Version 3.x.x Breaking Changes](#version-3xx-breaking-changes)
@@ -620,6 +623,41 @@ handlers:
620623

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

626+
## Django middleware request context
627+
628+
`AddToThreadContextMiddleware` is a [Middleware](https://docs.djangoproject.com/en/5.1/topics/http/middleware/) with which you can add the [Django](https://www.djangoproject.com/) [HttpRequest](https://docs.djangoproject.com/en/5.1/ref/request-response/#httprequest-objects) to thread local variables. The request object is added to a global variable in `logging_utilities.thread_context` and can be accessed in the following way:
629+
630+
```python
631+
from logging_utilities.thread_context import thread_context
632+
633+
getattr(thread_context, 'request')
634+
```
635+
636+
## Log thread context
637+
638+
`AddThreadContextFilter` provides a logging filter that will add data from the thread local store `logging_utilities.thread_context` to the log record. To set data on the thread store do the following:
639+
640+
```python
641+
from logging_utilities.thread_context import thread_context
642+
643+
setattr(thread_context, 'key', data)
644+
```
645+
646+
Configure the filter to decide which data should be added and how it should be named:
647+
648+
```yaml
649+
filters:
650+
add_request:
651+
(): logging_utilities.filters.add_thread_context_filter.AddThreadContextFilter
652+
contexts:
653+
- logger_key: log_record_key
654+
context_key: key
655+
```
656+
657+
| Parameter | Type | Default | Description |
658+
|------------|------|---------|------------------------------------------------|
659+
| `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. |
660+
623661
## Basic Usage
624662

625663
### Case 1. Simple JSON Output
@@ -1279,6 +1317,57 @@ output:
12791317
2020-11-19 13:42:29,424 - DEBUG - your_logger - My log with extras - extra1=23
12801318
```
12811319

1320+
### Case 9. Django add request info to all log records
1321+
1322+
Combine the use of the middleware `AddToThreadContextMiddleware` with the filters `AddThreadContextFilter` and `JsonDjangoRequest`, as well as the `JsonFormatter` to add request context to each log entry.
1323+
1324+
Activate the [middleware](https://docs.djangoproject.com/en/5.1/topics/http/middleware/#activating-middleware):
1325+
1326+
```python
1327+
MIDDLEWARE = (
1328+
...,
1329+
'logging_utilities.django_middlewares.add_request_context.AddToThreadContextMiddleware',
1330+
...,
1331+
)
1332+
```
1333+
1334+
Configure the filters `AddThreadContextFilter` and `JsonDjangoRequest` to add the request from the thread variable to the log record and make it json encodable. Use the `JsonFormatter` to format the request values
1335+
1336+
```yaml
1337+
filters:
1338+
add_request:
1339+
(): logging_utilities.filters.add_thread_context_filter.AddThreadContextFilter
1340+
contexts:
1341+
- context_key: request # Must be value 'request' as this is how the middleware adds the value.
1342+
logger_key: request
1343+
request_fields:
1344+
(): logging_utilities.filters.django_request.JsonDjangoRequest
1345+
attr_key: request # Must match the above logger_key
1346+
include_keys:
1347+
- request.path
1348+
- request.method
1349+
formatters:
1350+
json:
1351+
(): logging_utilities.formatters.json_formatter.JsonFormatter
1352+
fmt:
1353+
time: asctime
1354+
level: levelname
1355+
logger: name
1356+
module: module
1357+
message: message
1358+
request:
1359+
path: request.path
1360+
method: request.method
1361+
handlers:
1362+
console:
1363+
formatter: json
1364+
filters:
1365+
# Make sure to add the filters in the correct order.
1366+
# These filters modify the record in-place, and as the record is passed serially to each handler.
1367+
- add_request
1368+
- request_fields
1369+
```
1370+
12821371
## Breaking Changes
12831372
12841373
### Version 4.x.x Breaking Changes
@@ -1305,3 +1394,5 @@ From version 1.x.x to version 2.x.x there is the following breaking change:
13051394
## Credits
13061395

13071396
The JSON Formatter implementation has been inspired by [MyColorfulDays/jsonformatter](https://github.com/MyColorfulDays/jsonformatter)
1397+
1398+
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from threading import local
2+
3+
4+
class ThreadContext(local):
5+
"""ThreadContext is a store for data that is thread specific.
6+
"""
7+
8+
9+
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)