Skip to content

Commit 6c98817

Browse files
Merge pull request #256 from bento-platform/feat/logging/django-access-middleware
feat(logging): create Django request middleware
2 parents 74f7021 + 41a67c9 commit 6c98817

File tree

7 files changed

+165
-33
lines changed

7 files changed

+165
-33
lines changed
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from .configure import configure_structlog
1+
from .configure import configure_structlog, configure_structlog_uvicorn
22

3-
# re-export configure_structlog() function
4-
__all__ = ["configure_structlog"]
3+
# re-export functions:
4+
# - configure_structlog()
5+
# - configure_structlog_uvicorn()
6+
__all__ = ["configure_structlog", "configure_structlog_uvicorn"]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import time
2+
from pydantic import BaseModel
3+
from structlog.stdlib import BoundLogger
4+
5+
__all__ = [
6+
"LogHTTPInfo",
7+
"LogNetworkInfo",
8+
"LogNetworkClientInfo",
9+
"log_access",
10+
]
11+
12+
13+
class LogHTTPInfo(BaseModel):
14+
url: str
15+
status_code: int
16+
method: str
17+
version: str | None # If None, not known (in the case of the Django development server)
18+
19+
20+
class LogNetworkClientInfo(BaseModel):
21+
host: str | None
22+
port: int | None
23+
24+
25+
class LogNetworkInfo(BaseModel):
26+
client: LogNetworkClientInfo
27+
28+
29+
def _client_str(client: LogNetworkClientInfo) -> str:
30+
return f"{client.host or ''}{':' + str(client.port) if client.port else ''}"
31+
32+
33+
def _http_str(http: LogHTTPInfo) -> str:
34+
http_str = f"HTTP/{http.version}" if http.version else "HTTP"
35+
return f'"{http.method} {http.url} {http_str}" {http.status_code}'
36+
37+
38+
async def log_access(logger: BoundLogger, start_time_ns: int, http_info: LogHTTPInfo, network_info: LogNetworkInfo):
39+
# When the response has finished or errored out, write the access log message:
40+
41+
duration = time.perf_counter_ns() - start_time_ns
42+
43+
await logger.ainfo(
44+
# The message format mirrors the original uvicorn access message, which we aim to replace here with
45+
# something more structured.
46+
f"{_client_str(network_info.client) or '<unknown>'} - {_http_str(http_info)}",
47+
# HTTP information, extracted from the request and response objects:
48+
http=http_info.model_dump(mode="json", exclude_none=True),
49+
# Network information, extracted from the request object:
50+
network=network_info.model_dump(mode="json"),
51+
# Duration in nanoseconds, computed in-middleware:
52+
duration=duration,
53+
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import time
2+
3+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
4+
from django.http import HttpRequest, HttpResponse
5+
from rest_framework import status
6+
from structlog.stdlib import BoundLogger
7+
from typing import Awaitable, Callable
8+
9+
from .common import LogHTTPInfo, LogNetworkInfo, LogNetworkClientInfo, log_access
10+
11+
__all__ = [
12+
"BentoDjangoAccessLoggerMiddleware",
13+
]
14+
15+
16+
class BentoDjangoAccessLoggerMiddleware:
17+
"""
18+
Quasi-factory class to build an access-logging Django middleware class via the make_django_middleware() method.
19+
Similar to the authorization middleware for Django/FastAPI, we set this up as a "middleware factory" so that we can
20+
inject logger instances.
21+
"""
22+
23+
def __init__(self, access_logger: BoundLogger, service_logger: BoundLogger):
24+
self._access_logger = access_logger
25+
self._service_logger = service_logger
26+
27+
def make_django_middleware(self):
28+
class InnerMiddleware:
29+
async_capable = True
30+
sync_capable = False
31+
32+
# noinspection PyMethodParameters
33+
def __init__(inner_self, get_response: Callable[[HttpRequest], Awaitable[HttpResponse]]):
34+
inner_self.get_response = get_response
35+
if iscoroutinefunction(inner_self.get_response): # pragma: no cover
36+
markcoroutinefunction(inner_self)
37+
38+
# noinspection PyMethodParameters
39+
async def __call__(inner_self, request: HttpRequest):
40+
start_time = time.perf_counter_ns()
41+
42+
response: HttpResponse = HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
43+
try:
44+
response = await inner_self.get_response(request)
45+
except Exception as e: # pragma: no cover
46+
await self._service_logger.aexception("uncaught exception", exc_info=e)
47+
finally:
48+
# When the response has finished or errored out, write the access log message:
49+
# noinspection PyTypeChecker
50+
await log_access(
51+
self._access_logger,
52+
start_time,
53+
http_info=LogHTTPInfo(
54+
url=request.get_full_path(),
55+
status_code=response.status_code,
56+
method=request.method,
57+
version=None,
58+
),
59+
network_info=LogNetworkInfo(
60+
client=LogNetworkClientInfo(
61+
# Match what uvicorn shows:
62+
host=request.META.get("REMOTE_HOST"),
63+
port=request.META.get("REMOTE_PORT"),
64+
)
65+
),
66+
)
67+
68+
return response
69+
70+
return InnerMiddleware

bento_lib/logging/structured/fastapi.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import structlog
22
import time
3-
from fastapi import Request, Response
3+
from fastapi import Request, Response, status
44
from uvicorn.protocols.utils import get_path_with_query_string
55

6+
from .common import LogHTTPInfo, LogNetworkInfo, LogNetworkClientInfo, log_access
7+
68
__all__ = [
79
"build_structlog_fastapi_middleware",
810
]
@@ -24,40 +26,27 @@ async def access_log_middleware(request: Request, call_next) -> Response:
2426
start_time = time.perf_counter_ns()
2527

2628
# To return if an exception occurs while calling the next in the middleware chain
27-
response: Response = Response(status_code=500)
29+
response: Response = Response(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
2830

2931
try:
3032
response = await call_next(request)
3133
except Exception as e: # pragma: no cover
3234
await service_logger.aexception("uncaught exception", exc_info=e)
3335
finally:
3436
# When the response has finished or errored out, write the access log message:
35-
36-
duration = time.perf_counter_ns() - start_time
37-
38-
status_code = response.status_code
3937
# noinspection PyTypeChecker
40-
url = get_path_with_query_string(request.scope)
41-
client_host = request.client.host
42-
client_port = request.client.port
43-
http_method = request.method
44-
http_version = request.scope["http_version"]
45-
46-
await access_logger.ainfo(
47-
# The message format mirrors the original uvicorn access message, which we aim to replace here with
48-
# something more structured.
49-
f'{client_host}:{client_port} - "{http_method} {url} HTTP/{http_version}" {status_code}',
50-
# HTTP information, extracted from the request and response objects:
51-
http={
52-
"url": url,
53-
"status_code": status_code,
54-
"method": http_method,
55-
"version": http_version,
56-
},
57-
# Network information, extracted from the request object:
58-
network={"client": {"host": client_host, "port": client_port}},
59-
# Duration in nanoseconds, computed in-middleware:
60-
duration=duration,
38+
await log_access(
39+
access_logger,
40+
start_time,
41+
http_info=LogHTTPInfo(
42+
url=get_path_with_query_string(request.scope),
43+
status_code=response.status_code,
44+
method=request.method,
45+
version=request.scope["http_version"],
46+
),
47+
network_info=LogNetworkInfo(
48+
client=LogNetworkClientInfo(host=request.client.host, port=request.client.port)
49+
),
6150
)
6251

6352
return response
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import logging
1+
import structlog.stdlib
2+
from bento_lib.logging.structured.django import BentoDjangoAccessLoggerMiddleware
23

3-
__all__ = ["logger"]
4+
__all__ = ["logger", "access_middleware"]
45

5-
logger = logging.getLogger(__name__)
6+
logger = structlog.stdlib.get_logger("test.logger")
7+
8+
access = BentoDjangoAccessLoggerMiddleware(
9+
access_logger=structlog.stdlib.get_logger("test.access"),
10+
service_logger=logger,
11+
)
12+
access_middleware = access.make_django_middleware()

tests/django_test_project/django_test_project/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"django.contrib.messages.middleware.MessageMiddleware",
5151
"django.middleware.clickjacking.XFrameOptionsMiddleware",
5252
"tests.django_test_project.django_test_project.authz.authz_middleware",
53+
"tests.django_test_project.django_test_project.logger.access_middleware",
5354
]
5455

5556
ROOT_URLCONF = "tests.django_test_project.django_test_project.urls"

tests/test_platform_django.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
import responses
3+
import structlog.stdlib
34
from django.http import JsonResponse
45
from django.test import Client
56
from tests.django_test_project.django_test_project.authz import authz
@@ -56,3 +57,12 @@ def test_disabled(client: Client):
5657
authz._enabled = False
5758
r = client.post("/post-private", data=TEST_AUTHZ_VALID_POST_BODY, content_type="application/json")
5859
assert r.status_code == 200
60+
61+
62+
def test_django_access_logger_middleware_init():
63+
from bento_lib.logging.structured.django import BentoDjangoAccessLoggerMiddleware
64+
65+
BentoDjangoAccessLoggerMiddleware(
66+
access_logger=structlog.stdlib.get_logger("test.access"),
67+
service_logger=structlog.stdlib.get_logger("test.logger"),
68+
).make_django_middleware()

0 commit comments

Comments
 (0)