Skip to content

Commit 4c2e49a

Browse files
authored
Merge pull request #1024 from offbyone/push-tkpuvxrrvkvu
Make the log level for the request middleware fully configurable
2 parents dd60535 + 770a6b2 commit 4c2e49a

6 files changed

Lines changed: 176 additions & 37 deletions

File tree

django_structlog/app_settings.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,50 @@ class AppSettings:
1111
def CELERY_ENABLED(self) -> bool:
1212
return getattr(settings, self.PREFIX + "CELERY_ENABLED", False)
1313

14+
@property
15+
def CELERY_DEFAULT_LOG_LEVEL(self) -> int:
16+
return getattr(settings, self.PREFIX + "CELERY_DEFAULT_LOG_LEVEL", logging.INFO)
17+
18+
@property
19+
def CELERY_TASK_START_LOG_LEVEL(self) -> int:
20+
return getattr(
21+
settings,
22+
self.PREFIX + "CELERY_TASK_START_LOG_LEVEL",
23+
self.CELERY_DEFAULT_LOG_LEVEL,
24+
)
25+
26+
@property
27+
def CELERY_TASK_SUCCESS_LOG_LEVEL(self) -> int:
28+
return getattr(
29+
settings,
30+
self.PREFIX + "CELERY_TASK_SUCCESS_LOG_LEVEL",
31+
self.CELERY_DEFAULT_LOG_LEVEL,
32+
)
33+
34+
@property
35+
def CELERY_TASK_NOTICE_LOG_LEVEL(self) -> int:
36+
return getattr(
37+
settings,
38+
self.PREFIX + "CELERY_TASK_RETRY_LOG_LEVEL",
39+
logging.WARNING,
40+
)
41+
42+
@property
43+
def CELERY_TASK_FAILURE_LOG_LEVEL(self) -> int:
44+
return getattr(
45+
settings,
46+
self.PREFIX + "CELERY_TASK_FAILURE_LOG_LEVEL",
47+
logging.INFO,
48+
)
49+
50+
@property
51+
def CELERY_TASK_ERROR_LOG_LEVEL(self) -> int:
52+
return getattr(
53+
settings,
54+
self.PREFIX + "CELERY_TASK_ERROR_LOG_LEVEL",
55+
logging.ERROR,
56+
)
57+
1458
@property
1559
def IP_LOGGING_ENABLED(self) -> bool:
1660
return getattr(settings, self.PREFIX + "IP_LOGGING_ENABLED", True)
@@ -21,6 +65,26 @@ def REQUEST_CANCELLED_LOG_LEVEL(self) -> int:
2165
settings, self.PREFIX + "REQUEST_CANCELLED_LOG_LEVEL", logging.WARNING
2266
)
2367

68+
@property
69+
def STATUS_DEFAULT_LOG_LEVEL(self) -> int:
70+
return getattr(settings, self.PREFIX + "STATUS_DEFAULT_LOG_LEVEL", logging.INFO)
71+
72+
@property
73+
def STATUS_START_LOG_LEVEL(self) -> int:
74+
return getattr(
75+
settings,
76+
self.PREFIX + "STATUS_START_LOG_LEVEL",
77+
self.STATUS_DEFAULT_LOG_LEVEL,
78+
)
79+
80+
@property
81+
def STATUS_2XX_LOG_LEVEL(self) -> int:
82+
return getattr(
83+
settings,
84+
self.PREFIX + "STATUS_2XX_LOG_LEVEL",
85+
self.STATUS_DEFAULT_LOG_LEVEL,
86+
)
87+
2488
@property
2589
def STATUS_4XX_LOG_LEVEL(self) -> int:
2690
return getattr(settings, self.PREFIX + "STATUS_4XX_LOG_LEVEL", logging.WARNING)

django_structlog/celery/receivers.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
task_unknown,
1616
)
1717

18+
from ..app_settings import app_settings
1819
from . import signals
1920

2021
if TYPE_CHECKING: # pragma: no cover
@@ -68,7 +69,8 @@ def receiver_after_task_publish(
6869
properties["priority"] = self._priority
6970
self._priority = None
7071

71-
logger.info(
72+
logger.log(
73+
app_settings.CELERY_TASK_START_LOG_LEVEL,
7274
"task_enqueued",
7375
child_task_id=(
7476
headers.get("id")
@@ -96,7 +98,9 @@ def receiver_task_prerun(
9698
)
9799
# Record the start time so we can log the task duration later.
98100
task.request._django_structlog_started_at = time.monotonic_ns()
99-
logger.info("task_started", task=task.name)
101+
logger.log(
102+
app_settings.CELERY_TASK_START_LOG_LEVEL, "task_started", task=task.name
103+
)
100104

101105
def receiver_task_retry(
102106
self,
@@ -105,7 +109,9 @@ def receiver_task_retry(
105109
einfo: Optional[Any] = None,
106110
**kwargs: Any,
107111
) -> None:
108-
logger.warning("task_retrying", reason=reason)
112+
logger.log(
113+
app_settings.CELERY_TASK_NOTICE_LOG_LEVEL, "task_retrying", reason=reason
114+
)
109115

110116
def receiver_task_success(
111117
self, result: Optional[str] = None, sender: Optional[Any] = None, **kwargs: Any
@@ -116,7 +122,9 @@ def receiver_task_success(
116122

117123
log_vars: dict[str, Any] = {}
118124
self.add_duration_ms(sender, log_vars)
119-
logger.info("task_succeeded", **log_vars)
125+
logger.log(
126+
app_settings.CELERY_TASK_SUCCESS_LOG_LEVEL, "task_succeeded", **log_vars
127+
)
120128

121129
def receiver_task_failure(
122130
self,
@@ -132,7 +140,8 @@ def receiver_task_failure(
132140
self.add_duration_ms(sender, log_vars)
133141
throws = getattr(sender, "throws", ())
134142
if isinstance(exception, throws):
135-
logger.info(
143+
logger.log(
144+
app_settings.CELERY_TASK_FAILURE_LOG_LEVEL,
136145
"task_failed",
137146
error=str(exception),
138147
**log_vars,
@@ -167,7 +176,8 @@ def receiver_task_revoked(
167176
metadata["task_id"] = request.id
168177
metadata["task"] = request.task
169178

170-
logger.warning(
179+
logger.log(
180+
app_settings.CELERY_TASK_NOTICE_LOG_LEVEL,
171181
"task_revoked",
172182
terminated=terminated,
173183
signum=signum.value if signum is not None else None,
@@ -184,7 +194,8 @@ def receiver_task_unknown(
184194
id: Optional[str] = None,
185195
**kwargs: Any,
186196
) -> None:
187-
logger.error(
197+
logger.log(
198+
app_settings.CELERY_TASK_ERROR_LOG_LEVEL,
188199
"task_not_found",
189200
task=name,
190201
task_id=id,

django_structlog/middlewares/request.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import asyncio
2-
import logging
32
import sys
43
import uuid
54
from typing import (
@@ -57,33 +56,33 @@ def sync_streaming_content_wrapper(
5756
streaming_content: Iterator[bytes], context: Any
5857
) -> Generator[bytes, None, None]:
5958
with structlog.contextvars.bound_contextvars(**context):
60-
logger.info("streaming_started")
59+
logger.log(app_settings.STATUS_START_LOG_LEVEL, "streaming_started")
6160
try:
6261
for chunk in streaming_content:
6362
yield chunk
6463
except Exception:
6564
logger.exception("streaming_failed")
6665
raise
6766
else:
68-
logger.info("streaming_finished")
67+
logger.log(app_settings.STATUS_2XX_LOG_LEVEL, "streaming_finished")
6968

7069

7170
async def async_streaming_content_wrapper(
7271
streaming_content: AsyncIterator[bytes], context: Any
7372
) -> AsyncGenerator[bytes, Any]:
7473
with structlog.contextvars.bound_contextvars(**context):
75-
logger.info("streaming_started")
74+
logger.log(app_settings.STATUS_START_LOG_LEVEL, "streaming_started")
7675
try:
7776
async for chunk in streaming_content:
7877
yield chunk
7978
except asyncio.CancelledError:
80-
logger.warning("streaming_cancelled")
79+
logger.log(app_settings.REQUEST_CANCELLED_LOG_LEVEL, "streaming_cancelled")
8180
raise
8281
except Exception:
8382
logger.exception("streaming_failed")
8483
raise
8584
else:
86-
logger.info("streaming_finished")
85+
logger.log(app_settings.STATUS_2XX_LOG_LEVEL, "streaming_finished")
8786

8887

8988
class RequestMiddleware:
@@ -105,6 +104,7 @@ def __init__(
105104
["HttpRequest"], Union["HttpResponse", Awaitable["HttpResponse"]]
106105
],
107106
) -> None:
107+
108108
self.get_response = get_response
109109
if iscoroutinefunction(self.get_response):
110110
markcoroutinefunction(self)
@@ -130,6 +130,18 @@ async def __acall__(self, request: "HttpRequest") -> "HttpResponse":
130130
await sync.sync_to_async(self.handle_response)(request, response)
131131
return response
132132

133+
def _log_level_for_status_code(self, status_code: int) -> int:
134+
match status_code // 100:
135+
case 2:
136+
level = app_settings.STATUS_2XX_LOG_LEVEL
137+
case 4:
138+
level = app_settings.STATUS_4XX_LOG_LEVEL
139+
case 5:
140+
level = app_settings.STATUS_5XX_LOG_LEVEL
141+
case _:
142+
level = app_settings.STATUS_DEFAULT_LOG_LEVEL
143+
return level
144+
133145
def handle_response(self, request: "HttpRequest", response: "HttpResponse") -> None:
134146
if not hasattr(request, "_raised_exception"):
135147
self.bind_user_id(request)
@@ -146,12 +158,7 @@ def handle_response(self, request: "HttpRequest", response: "HttpResponse") -> N
146158
response=response,
147159
log_kwargs=log_kwargs,
148160
)
149-
if response.status_code >= 500:
150-
level = app_settings.STATUS_5XX_LOG_LEVEL
151-
elif response.status_code >= 400:
152-
level = app_settings.STATUS_4XX_LOG_LEVEL
153-
else:
154-
level = logging.INFO
161+
level = self._log_level_for_status_code(response.status_code)
155162
logger.log(
156163
level,
157164
"request_finished",
@@ -200,7 +207,8 @@ def prepare(self, request: "HttpRequest") -> None:
200207
signals.bind_extra_request_metadata.send(
201208
sender=self.__class__, request=request, logger=logger, log_kwargs=log_kwargs
202209
)
203-
logger.info("request_started", **log_kwargs)
210+
level = app_settings.STATUS_START_LOG_LEVEL
211+
logger.log(level, "request_started", **log_kwargs)
204212

205213
@classmethod
206214
def bind_ip(cls, request: "HttpRequest") -> None:

docs/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Change Log
22
==========
33

4+
Unreleased
5+
----------
6+
7+
*New:*
8+
- Add settings to configure the logging levels for the request middleware and celery task events. See `#1022 <https://github.com/jrobichaud/django-structlog/issues/1022>`_.
9+
410
10.0.0 (October 22, 2025)
511
-------------------------
612

docs/configuration.rst

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,38 @@ Example:
1818
Settings
1919
--------
2020

21-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
22-
| Key | Type | Default | Description |
23-
+==========================================+=========+=================+===============================================================================+
24-
| DJANGO_STRUCTLOG_CELERY_ENABLED | boolean | False | See :ref:`celery_integration` |
25-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
26-
| DJANGO_STRUCTLOG_IP_LOGGING_ENABLED | boolean | True | automatically bind user ip using `django-ipware` |
27-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
28-
| DJANGO_STRUCTLOG_STATUS_4XX_LOG_LEVEL | int | logging.WARNING | Log level of 4XX status codes |
29-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
30-
| DJANGO_STRUCTLOG_STATUS_5XX_LOG_LEVEL | int | logging.ERROR | Log level of 5XX status codes |
31-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
32-
| DJANGO_STRUCTLOG_REQUEST_CANCELLED_LOG_LEVEL | int | logging.WARNING | Log level of request_cancelled messages |
33-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
34-
| DJANGO_STRUCTLOG_COMMAND_LOGGING_ENABLED | boolean | False | See :ref:`commands` |
35-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
36-
| DJANGO_STRUCTLOG_USER_ID_FIELD | string | ``"pk"`` | Change field used to identify user in logs, ``None`` to disable user binding |
37-
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
21+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
22+
| Key | Type | Default | Description |
23+
+==================================================+=========+=================+==============================================================================+
24+
| DJANGO_STRUCTLOG_CELERY_ENABLED | boolean | False | See :ref:`celery_integration` |
25+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
26+
| DJANGO_STRUCTLOG_CELERY_DEFAULT_LOG_LEVEL | int | logging.INFO | The default log level for celery task events |
27+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
28+
| DJANGO_STRUCTLOG_CELERY_TASK_START_LOG_LEVEL | int | logging.INFO | Log level for task_enqueued and task_started events |
29+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
30+
| DJANGO_STRUCTLOG_CELERY_TASK_SUCCESS_LOG_LEVEL | int | logging.INFO | Log level for task_succeeded events |
31+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
32+
| DJANGO_STRUCTLOG_CELERY_TASK_NOTICE_LOG_LEVEL | int | logging.WARNING | Log level for task_retrying and task_revoked events |
33+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
34+
| DJANGO_STRUCTLOG_CELERY_TASK_FAILURE_LOG_LEVEL | int | logging.INFO | Log level for task_failed |
35+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
36+
| DJANGO_STRUCTLOG_CELERY_TASK_ERROR_LOG_LEVEL | int | logging.ERROR | Log level for true errors using Celery |
37+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
38+
| DJANGO_STRUCTLOG_IP_LOGGING_ENABLED | boolean | True | automatically bind user ip using `django-ipware` |
39+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
40+
| DJANGO_STRUCTLOG_DEFAULT_LOG_LEVEL | int | logging.INFO | The default log level for non-error statuses |
41+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
42+
| DJANGO_STRUCTLOG_START_LOG_LEVEL | int | logging.INFO | The level at which request starts are logged |
43+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
44+
| DJANGO_STRUCTLOG_STATUS_2XX_LOG_LEVEL | int | logging.INFO | The level of 2XX status codes |
45+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
46+
| DJANGO_STRUCTLOG_STATUS_4XX_LOG_LEVEL | int | logging.WARNING | Log level of 4XX status codes |
47+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
48+
| DJANGO_STRUCTLOG_STATUS_5XX_LOG_LEVEL | int | logging.ERROR | Log level of 5XX status codes |
49+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
50+
| DJANGO_STRUCTLOG_REQUEST_CANCELLED_LOG_LEVEL | int | logging.WARNING | Log level of request_cancelled messages |
51+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
52+
| DJANGO_STRUCTLOG_COMMAND_LOGGING_ENABLED | boolean | False | See :ref:`commands` |
53+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
54+
| DJANGO_STRUCTLOG_USER_ID_FIELD | string | ``"pk"`` | Change field used to identify user in logs, ``None`` to disable user binding |
55+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+

test_app/tests/middlewares/test_request.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,3 +1369,35 @@ async def streaming_content() -> AsyncGenerator[Any, None]:
13691369
self.assertEqual("streaming_cancelled", record.msg["event"])
13701370
self.assertIn("foo", record.msg)
13711371
self.assertEqual("bar", record.msg["foo"])
1372+
1373+
1374+
class TestLogLevelMappings(TestCase):
1375+
def test_log_level_for_status_code(self) -> None:
1376+
middleware = RequestMiddleware(lambda r: HttpResponse())
1377+
from django_structlog.app_settings import app_settings
1378+
1379+
# 2xx
1380+
self.assertEqual(
1381+
middleware._log_level_for_status_code(200),
1382+
app_settings.STATUS_2XX_LOG_LEVEL,
1383+
)
1384+
# 4xx
1385+
self.assertEqual(
1386+
middleware._log_level_for_status_code(404),
1387+
app_settings.STATUS_4XX_LOG_LEVEL,
1388+
)
1389+
# 5xx
1390+
self.assertEqual(
1391+
middleware._log_level_for_status_code(500),
1392+
app_settings.STATUS_5XX_LOG_LEVEL,
1393+
)
1394+
# 3xx (default branch)
1395+
self.assertEqual(
1396+
middleware._log_level_for_status_code(301),
1397+
app_settings.STATUS_DEFAULT_LOG_LEVEL,
1398+
)
1399+
# 1xx (default branch)
1400+
self.assertEqual(
1401+
middleware._log_level_for_status_code(100),
1402+
app_settings.STATUS_DEFAULT_LOG_LEVEL,
1403+
)

0 commit comments

Comments
 (0)