Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions django_structlog/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,50 @@ class AppSettings:
def CELERY_ENABLED(self) -> bool:
return getattr(settings, self.PREFIX + "CELERY_ENABLED", False)

@property
def CELERY_DEFAULT_LOG_LEVEL(self) -> int:
return getattr(settings, self.PREFIX + "CELERY_DEFAULT_LOG_LEVEL", logging.INFO)

@property
def CELERY_TASK_START_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "CELERY_TASK_START_LOG_LEVEL",
self.CELERY_DEFAULT_LOG_LEVEL,
)

@property
def CELERY_TASK_SUCCESS_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "CELERY_TASK_SUCCESS_LOG_LEVEL",
self.CELERY_DEFAULT_LOG_LEVEL,
)

@property
def CELERY_TASK_NOTICE_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "CELERY_TASK_RETRY_LOG_LEVEL",
logging.WARNING,
)

@property
def CELERY_TASK_FAILURE_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "CELERY_TASK_FAILURE_LOG_LEVEL",
logging.INFO,
)

@property
def CELERY_TASK_ERROR_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "CELERY_TASK_ERROR_LOG_LEVEL",
logging.ERROR,
)

@property
def IP_LOGGING_ENABLED(self) -> bool:
return getattr(settings, self.PREFIX + "IP_LOGGING_ENABLED", True)
Expand All @@ -21,6 +65,26 @@ def REQUEST_CANCELLED_LOG_LEVEL(self) -> int:
settings, self.PREFIX + "REQUEST_CANCELLED_LOG_LEVEL", logging.WARNING
)

@property
def STATUS_DEFAULT_LOG_LEVEL(self) -> int:
return getattr(settings, self.PREFIX + "STATUS_DEFAULT_LOG_LEVEL", logging.INFO)

@property
def STATUS_START_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "STATUS_START_LOG_LEVEL",
self.STATUS_DEFAULT_LOG_LEVEL,
)

@property
def STATUS_2XX_LOG_LEVEL(self) -> int:
return getattr(
settings,
self.PREFIX + "STATUS_2XX_LOG_LEVEL",
self.STATUS_DEFAULT_LOG_LEVEL,
)

@property
def STATUS_4XX_LOG_LEVEL(self) -> int:
return getattr(settings, self.PREFIX + "STATUS_4XX_LOG_LEVEL", logging.WARNING)
Expand Down
25 changes: 18 additions & 7 deletions django_structlog/celery/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
task_unknown,
)

from ..app_settings import app_settings
from . import signals

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

logger.info(
logger.log(
app_settings.CELERY_TASK_START_LOG_LEVEL,
"task_enqueued",
child_task_id=(
headers.get("id")
Expand Down Expand Up @@ -96,7 +98,9 @@ def receiver_task_prerun(
)
# Record the start time so we can log the task duration later.
task.request._django_structlog_started_at = time.monotonic_ns()
logger.info("task_started", task=task.name)
logger.log(
app_settings.CELERY_TASK_START_LOG_LEVEL, "task_started", task=task.name
)

def receiver_task_retry(
self,
Expand All @@ -105,7 +109,9 @@ def receiver_task_retry(
einfo: Optional[Any] = None,
**kwargs: Any,
) -> None:
logger.warning("task_retrying", reason=reason)
logger.log(
app_settings.CELERY_TASK_NOTICE_LOG_LEVEL, "task_retrying", reason=reason
)

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

log_vars: dict[str, Any] = {}
self.add_duration_ms(sender, log_vars)
logger.info("task_succeeded", **log_vars)
logger.log(
app_settings.CELERY_TASK_SUCCESS_LOG_LEVEL, "task_succeeded", **log_vars
)

def receiver_task_failure(
self,
Expand All @@ -132,7 +140,8 @@ def receiver_task_failure(
self.add_duration_ms(sender, log_vars)
throws = getattr(sender, "throws", ())
if isinstance(exception, throws):
logger.info(
logger.log(
app_settings.CELERY_TASK_FAILURE_LOG_LEVEL,
"task_failed",
error=str(exception),
**log_vars,
Expand Down Expand Up @@ -167,7 +176,8 @@ def receiver_task_revoked(
metadata["task_id"] = request.id
metadata["task"] = request.task

logger.warning(
logger.log(
app_settings.CELERY_TASK_NOTICE_LOG_LEVEL,
"task_revoked",
terminated=terminated,
signum=signum.value if signum is not None else None,
Expand All @@ -184,7 +194,8 @@ def receiver_task_unknown(
id: Optional[str] = None,
**kwargs: Any,
) -> None:
logger.error(
logger.log(
app_settings.CELERY_TASK_ERROR_LOG_LEVEL,
"task_not_found",
task=name,
task_id=id,
Expand Down
34 changes: 21 additions & 13 deletions django_structlog/middlewares/request.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import asyncio
import logging
import sys
import uuid
from typing import (
Expand Down Expand Up @@ -57,33 +56,33 @@ def sync_streaming_content_wrapper(
streaming_content: Iterator[bytes], context: Any
) -> Generator[bytes, None, None]:
with structlog.contextvars.bound_contextvars(**context):
logger.info("streaming_started")
logger.log(app_settings.STATUS_START_LOG_LEVEL, "streaming_started")
try:
for chunk in streaming_content:
yield chunk
except Exception:
logger.exception("streaming_failed")
raise
else:
logger.info("streaming_finished")
logger.log(app_settings.STATUS_2XX_LOG_LEVEL, "streaming_finished")


async def async_streaming_content_wrapper(
streaming_content: AsyncIterator[bytes], context: Any
) -> AsyncGenerator[bytes, Any]:
with structlog.contextvars.bound_contextvars(**context):
logger.info("streaming_started")
logger.log(app_settings.STATUS_START_LOG_LEVEL, "streaming_started")
try:
async for chunk in streaming_content:
yield chunk
except asyncio.CancelledError:
logger.warning("streaming_cancelled")
logger.log(app_settings.REQUEST_CANCELLED_LOG_LEVEL, "streaming_cancelled")
raise
except Exception:
logger.exception("streaming_failed")
raise
else:
logger.info("streaming_finished")
logger.log(app_settings.STATUS_2XX_LOG_LEVEL, "streaming_finished")


class RequestMiddleware:
Expand All @@ -105,6 +104,7 @@ def __init__(
["HttpRequest"], Union["HttpResponse", Awaitable["HttpResponse"]]
],
) -> None:

self.get_response = get_response
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
Expand All @@ -130,6 +130,18 @@ async def __acall__(self, request: "HttpRequest") -> "HttpResponse":
await sync.sync_to_async(self.handle_response)(request, response)
return response

def _log_level_for_status_code(self, status_code: int) -> int:
match status_code // 100:
case 2:
level = app_settings.STATUS_2XX_LOG_LEVEL
case 4:
level = app_settings.STATUS_4XX_LOG_LEVEL
case 5:
level = app_settings.STATUS_5XX_LOG_LEVEL
case _:
level = app_settings.STATUS_DEFAULT_LOG_LEVEL
return level

def handle_response(self, request: "HttpRequest", response: "HttpResponse") -> None:
if not hasattr(request, "_raised_exception"):
self.bind_user_id(request)
Expand All @@ -146,12 +158,7 @@ def handle_response(self, request: "HttpRequest", response: "HttpResponse") -> N
response=response,
log_kwargs=log_kwargs,
)
if response.status_code >= 500:
level = app_settings.STATUS_5XX_LOG_LEVEL
elif response.status_code >= 400:
level = app_settings.STATUS_4XX_LOG_LEVEL
else:
level = logging.INFO
level = self._log_level_for_status_code(response.status_code)
logger.log(
level,
"request_finished",
Expand Down Expand Up @@ -200,7 +207,8 @@ def prepare(self, request: "HttpRequest") -> None:
signals.bind_extra_request_metadata.send(
sender=self.__class__, request=request, logger=logger, log_kwargs=log_kwargs
)
logger.info("request_started", **log_kwargs)
level = app_settings.STATUS_START_LOG_LEVEL
logger.log(level, "request_started", **log_kwargs)

@classmethod
def bind_ip(cls, request: "HttpRequest") -> None:
Expand Down
6 changes: 6 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Change Log
==========

Unreleased
----------

*New:*
- 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>`_.

10.0.0 (October 22, 2025)
-------------------------

Expand Down
52 changes: 35 additions & 17 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,38 @@ Example:
Settings
--------

+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| Key | Type | Default | Description |
+==========================================+=========+=================+===============================================================================+
| DJANGO_STRUCTLOG_CELERY_ENABLED | boolean | False | See :ref:`celery_integration` |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_IP_LOGGING_ENABLED | boolean | True | automatically bind user ip using `django-ipware` |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_STATUS_4XX_LOG_LEVEL | int | logging.WARNING | Log level of 4XX status codes |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_STATUS_5XX_LOG_LEVEL | int | logging.ERROR | Log level of 5XX status codes |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_REQUEST_CANCELLED_LOG_LEVEL | int | logging.WARNING | Log level of request_cancelled messages |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_COMMAND_LOGGING_ENABLED | boolean | False | See :ref:`commands` |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_USER_ID_FIELD | string | ``"pk"`` | Change field used to identify user in logs, ``None`` to disable user binding |
+------------------------------------------+---------+-----------------+-------------------------------------------------------------------------------+
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| Key | Type | Default | Description |
+==================================================+=========+=================+==============================================================================+
| DJANGO_STRUCTLOG_CELERY_ENABLED | boolean | False | See :ref:`celery_integration` |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_CELERY_DEFAULT_LOG_LEVEL | int | logging.INFO | The default log level for celery task events |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_CELERY_TASK_START_LOG_LEVEL | int | logging.INFO | Log level for task_enqueued and task_started events |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_CELERY_TASK_SUCCESS_LOG_LEVEL | int | logging.INFO | Log level for task_succeeded events |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_CELERY_TASK_NOTICE_LOG_LEVEL | int | logging.WARNING | Log level for task_retrying and task_revoked events |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_CELERY_TASK_FAILURE_LOG_LEVEL | int | logging.INFO | Log level for task_failed |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_CELERY_TASK_ERROR_LOG_LEVEL | int | logging.ERROR | Log level for true errors using Celery |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_IP_LOGGING_ENABLED | boolean | True | automatically bind user ip using `django-ipware` |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_DEFAULT_LOG_LEVEL | int | logging.INFO | The default log level for non-error statuses |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_START_LOG_LEVEL | int | logging.INFO | The level at which request starts are logged |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_STATUS_2XX_LOG_LEVEL | int | logging.INFO | The level of 2XX status codes |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_STATUS_4XX_LOG_LEVEL | int | logging.WARNING | Log level of 4XX status codes |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_STATUS_5XX_LOG_LEVEL | int | logging.ERROR | Log level of 5XX status codes |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_REQUEST_CANCELLED_LOG_LEVEL | int | logging.WARNING | Log level of request_cancelled messages |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_COMMAND_LOGGING_ENABLED | boolean | False | See :ref:`commands` |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
| DJANGO_STRUCTLOG_USER_ID_FIELD | string | ``"pk"`` | Change field used to identify user in logs, ``None`` to disable user binding |
+--------------------------------------------------+---------+-----------------+------------------------------------------------------------------------------+
32 changes: 32 additions & 0 deletions test_app/tests/middlewares/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -1369,3 +1369,35 @@ async def streaming_content() -> AsyncGenerator[Any, None]:
self.assertEqual("streaming_cancelled", record.msg["event"])
self.assertIn("foo", record.msg)
self.assertEqual("bar", record.msg["foo"])


class TestLogLevelMappings(TestCase):
def test_log_level_for_status_code(self) -> None:
middleware = RequestMiddleware(lambda r: HttpResponse())
from django_structlog.app_settings import app_settings

# 2xx
self.assertEqual(
middleware._log_level_for_status_code(200),
app_settings.STATUS_2XX_LOG_LEVEL,
)
# 4xx
self.assertEqual(
middleware._log_level_for_status_code(404),
app_settings.STATUS_4XX_LOG_LEVEL,
)
# 5xx
self.assertEqual(
middleware._log_level_for_status_code(500),
app_settings.STATUS_5XX_LOG_LEVEL,
)
# 3xx (default branch)
self.assertEqual(
middleware._log_level_for_status_code(301),
app_settings.STATUS_DEFAULT_LOG_LEVEL,
)
# 1xx (default branch)
self.assertEqual(
middleware._log_level_for_status_code(100),
app_settings.STATUS_DEFAULT_LOG_LEVEL,
)