-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
PB-511: Django request context in logs - #minor #61
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -276,6 +276,7 @@ function-naming-style=snake_case | |
good-names=i, | ||
j, | ||
k, | ||
f, | ||
ex, | ||
fd, | ||
Run, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import logging | ||
from typing import Any | ||
from typing import Callable | ||
from typing import Optional | ||
from typing import Type | ||
from typing import TypeVar | ||
|
||
from django.core.handlers.wsgi import WSGIRequest | ||
|
||
from logging_utilities.filters.django_append_request import \ | ||
DjangoAppendRequestFilter | ||
|
||
# Create a generic variable that can be 'WrappedRequest', or any subclass. | ||
T = TypeVar('T', bound='WrappedRequest') | ||
|
||
|
||
class WrappedRequest(WSGIRequest): | ||
"""WrappedRequest adds the 'logging_filter' field to a standard request to track it. | ||
""" | ||
|
||
@classmethod | ||
def from_parent( | ||
cls: Type[T], parent: WSGIRequest, logging_filter: Optional[DjangoAppendRequestFilter] | ||
) -> T: | ||
return cls(parent.environ, logging_filter) | ||
|
||
def __init__(self, environ: Any, logging_filter: Optional[DjangoAppendRequestFilter]) -> None: | ||
super().__init__(environ) | ||
self.logging_filter = logging_filter | ||
|
||
|
||
class AddRequestToLogMiddleware(): | ||
"""Middleware that adds a logging filter *DjangoAppendRequestFilter* to the request. | ||
""" | ||
|
||
def __init__(self, get_response: Callable[[WSGIRequest], Any], root_logger: str = ""): | ||
self.root_logger = root_logger | ||
self.get_response = get_response | ||
|
||
def __call__(self, request: WSGIRequest) -> Any: | ||
w_request = WrappedRequest.from_parent(request, None) | ||
response = self.process_request(w_request) | ||
if not response: | ||
response = self.get_response(w_request) | ||
Comment on lines
+43
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I understand this part: the |
||
response = self.process_response(w_request, response) | ||
|
||
return response | ||
|
||
def _find_loggers(self) -> dict[str, logging.Logger]: | ||
"""Return loggers part of root. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a little more information about what you do here, i.e. what |
||
""" | ||
result: dict[str, logging.Logger] = {} | ||
prefix = self.root_logger + "." | ||
for name, log in logging.Logger.manager.loggerDict.items(): | ||
if not isinstance(log, logging.Logger) or not name.startswith(prefix): | ||
continue # not under self.root_logger | ||
result[name] = log | ||
# also add root logger | ||
result[self.root_logger] = logging.getLogger(self.root_logger) | ||
return result | ||
|
||
def _find_handlers(self) -> list[logging.Handler]: | ||
"""List handlers of all loggers | ||
""" | ||
handlers = set() | ||
for logger in self._find_loggers().values(): | ||
for handler in logger.handlers: | ||
handlers.add(handler) | ||
return list(handlers) | ||
|
||
def _find_handlers_with_filter(self, filter_cls: type) -> dict[logging.Handler, list[Any]]: | ||
"""Dict of handlers mapped to their filters. | ||
Only include handlers that have at least one filter of type *filter_cls*. | ||
""" | ||
result = {} | ||
for handler in self._find_handlers(): | ||
attrs = [] | ||
for f in handler.filters: | ||
if isinstance(f, filter_cls): | ||
attrs.extend(f.attributes) | ||
if attrs: | ||
result[handler] = attrs | ||
return result | ||
|
||
def _add_filter(self, f: DjangoAppendRequestFilter) -> None: | ||
"""Add the filter to relevant handlers. | ||
Relevant handlers are once that already include a filter of the same type. | ||
This is how we "overrite" the filter with the current request. | ||
""" | ||
filter_cls = type(f) | ||
for handler, attrs in self._find_handlers_with_filter(filter_cls).items(): | ||
f.attributes = attrs | ||
handler.addFilter(f) | ||
|
||
def process_request(self, request: WrappedRequest) -> Any: | ||
"""Add a filter that includes the current request. Add the filter to the request to be | ||
removed again later. | ||
""" | ||
request.logging_filter = DjangoAppendRequestFilter(request) | ||
self._add_filter(request.logging_filter) | ||
return self.get_response(request) | ||
|
||
def _remove_filter(self, f: DjangoAppendRequestFilter) -> None: | ||
"""Remove the filter from any handlers that may have it. | ||
""" | ||
filter_cls = type(f) | ||
for handler in self._find_handlers_with_filter(filter_cls): | ||
handler.removeFilter(f) | ||
|
||
def process_response(self, request: WrappedRequest, response: Any) -> Any: | ||
"""Remove the filter if set. | ||
""" | ||
if request.logging_filter: | ||
self._remove_filter(request.logging_filter) | ||
return response |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import functools | ||
from logging import LogRecord | ||
from typing import Optional | ||
|
||
from django.core.handlers.wsgi import WSGIRequest | ||
|
||
|
||
def request_getattr(obj, attr, *args): | ||
|
||
def _getattr(obj, attr): | ||
if isinstance(obj, dict): | ||
return obj.get(attr) | ||
return getattr(obj, attr, *args) | ||
|
||
return functools.reduce(_getattr, [obj] + attr.split('.')) | ||
|
||
|
||
class DjangoAppendRequestFilter(): | ||
"""Logging Django request attributes | ||
|
||
This filter adds Django request context attributes to the log record. | ||
""" | ||
|
||
def __init__(self, request: Optional[WSGIRequest] = None, attributes=None, always_add=False): | ||
"""Initialize the filter | ||
|
||
Args: | ||
request: (WSGIRequest | None) | ||
Request from which to read the attributes from. | ||
attributes: (list | None) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. here you are not consistent between docu and code, docu says |
||
Request attributes that should be added to log entries. | ||
always_add: bool | ||
Always add attributes even if they are missing. Missing attributes with have the | ||
value "-". | ||
""" | ||
self.request = request | ||
self.attributes = attributes if attributes else list() | ||
self.always_add = always_add | ||
|
||
def filter(self, record: LogRecord) -> bool: | ||
request = self.request | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in |
||
for attr in self.attributes: | ||
val = request_getattr(request, attr, "-") | ||
if self.always_add or val != "-": | ||
setattr(record, "request." + attr, val) | ||
|
||
return True |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import json | ||
import logging | ||
import sys | ||
import unittest | ||
from collections import OrderedDict | ||
|
||
from django.conf import settings | ||
from django.test import RequestFactory | ||
|
||
from logging_utilities.filters.django_append_request import \ | ||
DjangoAppendRequestFilter | ||
from logging_utilities.formatters.json_formatter import JsonFormatter | ||
|
||
# From python3.7, dict is ordered | ||
if sys.version_info.major >= 3 and sys.version_info.minor >= 7: | ||
dictionary = dict | ||
else: | ||
dictionary = OrderedDict | ||
|
||
if not settings.configured: | ||
settings.configure() | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class DjangoAppendRequestFilterTest(unittest.TestCase): | ||
|
||
def setUp(self) -> None: | ||
self.factory = RequestFactory() | ||
|
||
@classmethod | ||
def _configure_django_filter(cls, _logger, django_filter): | ||
_logger.setLevel(logging.DEBUG) | ||
for handler in _logger.handlers: | ||
handler.setFormatter(JsonFormatter(add_always_extra=True)) | ||
handler.addFilter(django_filter) | ||
|
||
def test_django_request_log(self): | ||
request = self.factory.get("/some_path?test=some_value") | ||
with self.assertLogs('test_formatter', level=logging.DEBUG) as ctx: | ||
test_logger = logging.getLogger("test_formatter") | ||
self._configure_django_filter( | ||
test_logger, | ||
DjangoAppendRequestFilter( | ||
request, attributes=["path", "method", "META.QUERY_STRING"] | ||
) | ||
) | ||
|
||
test_logger.debug("first message") | ||
test_logger.info("second message") | ||
|
||
message1 = json.loads(ctx.output[0], object_pairs_hook=dictionary) | ||
self.assertDictEqual( | ||
message1, | ||
dictionary([("levelname", "DEBUG"), ("name", "test_formatter"), | ||
("message", "first message"), ("request.method", "GET"), | ||
("request.path", "/some_path"), | ||
("request.META.QUERY_STRING", "test=some_value")]) | ||
) | ||
message2 = json.loads(ctx.output[1], object_pairs_hook=dictionary) | ||
self.assertDictEqual( | ||
message2, | ||
dictionary([("levelname", "INFO"), ("name", "test_formatter"), | ||
("message", "second message"), ("request.method", "GET"), | ||
("request.path", "/some_path"), | ||
("request.META.QUERY_STRING", "test=some_value")]) | ||
) | ||
|
||
def test_django_request_log_always_add(self): | ||
request = self.factory.get("/some_path?test=some_value") | ||
with self.assertLogs('test_formatter', level=logging.DEBUG) as ctx: | ||
test_logger = logging.getLogger("test_formatter") | ||
self._configure_django_filter( | ||
test_logger, | ||
DjangoAppendRequestFilter( | ||
request, attributes=["does", "not", "exist"], always_add=True | ||
) | ||
) | ||
|
||
test_logger.debug("first message") | ||
test_logger.info("second message") | ||
|
||
message1 = json.loads(ctx.output[0], object_pairs_hook=dictionary) | ||
self.assertDictEqual( | ||
message1, | ||
dictionary([("levelname", "DEBUG"), ("name", "test_formatter"), | ||
("message", "first message"), ("request.does", "-"), ("request.not", "-"), | ||
("request.exist", "-")]) | ||
) | ||
message2 = json.loads(ctx.output[1], object_pairs_hook=dictionary) | ||
self.assertDictEqual( | ||
message2, | ||
dictionary([("levelname", "INFO"), ("name", "test_formatter"), | ||
("message", "second message"), ("request.does", "-"), ("request.not", "-"), | ||
("request.exist", "-")]) | ||
) | ||
|
||
def test_django_request_log_no_request(self): | ||
with self.assertLogs('test_formatter', level=logging.DEBUG) as ctx: | ||
test_logger = logging.getLogger("test_formatter") | ||
self._configure_django_filter( | ||
test_logger, | ||
DjangoAppendRequestFilter(request=None, attributes=["path"], always_add=True) | ||
) | ||
|
||
test_logger.debug("first message") | ||
|
||
message1 = json.loads(ctx.output[0], object_pairs_hook=dictionary) | ||
self.assertDictEqual( | ||
message1, | ||
dictionary([("levelname", "DEBUG"), ("name", "test_formatter"), | ||
("message", "first message"), ("request.path", "-")]) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe you could be a little more verbose about the basic idea behind your solution with middleware and filter. With some not very deep understanding of how logging filters work this is not immediately obvious.