How can I make Django signals request-aware for audit logging

I am building an audit trail system in Django where I need access to the current HTTP request inside model signals (pre_save, post_save, pre_delete, post_delete).

The goal is to make signals “request-aware” so I can log:

  • authenticated user (request.user)

  • IP address

  • and other request metadata

without explicitly passing the request through every service layer call.

Since Django signals do not natively provide the request object, I tried solving this using contextvars.ContextVar. Below are the codes

# request_context.py
from contextvars import ContextVar

current_request = ContextVar("current_request", default=None)


def get_current_request():
    """Retrieve the current request from context."""
    return current_request.get()
# middleware.py
from core.request_context import current_request


class CurrentRequestMiddleware:
    """
    Stores the current HTTP request in a ContextVar
    so it can be accessed globally (e.g. inside signals).
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        print("SETTING REQUEST:", request)

        token = current_request.set(request)

        try:
            response = self.get_response(request)
        finally:
            current_request.reset(token)

        return response

And below is where i execute it in signals

from core.request_context import get_current_request

request = get_current_request()

if request:
    print(request.__dict__)
else:
    print("Request is None")

Expected behavior

I expected that since:

  • middleware executes successfully
  • request is set using ContextVar.set()
  • signals are triggered during normal request lifecycle (Django admin / API requests)

then get_current_request() inside signals should return the active HttpRequest. But ContextVar.get() returns None inside signal handlers. So now my questions is?

What is the correct way to make Django signals “request-aware” so that I can reliably access the current HttpRequest inside signals for audit logging? or better still, What is the recommended production-safe pattern for achieving request-aware audit logging in Django

I am aware that using Django signals for audit logging is generally considered an anti-pattern and not recommended for complex systems. However, in this project I am constrained by the following requirements:

  • I cannot use external dependencies or third-party audit libraries.
  • I need a built-in Django solution.
  • I must log all model-level actions (create, update, delete) to an audit table.
  • The system should capture request context (user, IP) whenever available. Audit logging must be automatic and not require explicit calls across the codebase.

The goal is to ensure that all Django ORM-driven operations on models are tracked centrally.

I have a working solution for you. I improved your code and modeled it based on a middleware I wrote for a FastAPI project I worked on.

# audit_context.py


from contextvars import ContextVar
from typing import Optional, Dict, Any

_audit_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("audit_context", default=None)


def set_audit_context(data: Dict[str, Any]) -> None:
    _audit_context.set(data)


def get_audit_context() -> Optional[Dict[str, Any]]:
    return _audit_context.get()
#  request_snapshot.py

import json

# This is to make sure not all META items are picked, given some of them are sensitive.
SAFE_META_PREFIXES = (
    "HTTP_",
    "REMOTE_ADDR",
    "CONTENT_TYPE",
    "CONTENT_LENGTH",
)


def build_request_snapshot(request):
    """
    Creates a FULL but SAFE serializable snapshot of Django request.
    """

    # --- HEADERS ---
    headers = dict(request.headers)

    # --- META (filtered) ---
    meta = {k: v for k, v in request.META.items() if k.startswith(SAFE_META_PREFIXES)}

    # --- BODY ---
    body = None
    try:
        raw = request.body

        if raw:
            text = raw.decode("utf-8", errors="ignore")

            # try JSON decode
            try:
                body = json.loads(text)
            except Exception:
                body = text

    except Exception as e:
        body = f"unavailable: {str(e)}"

    # --- COOKIES ---
    cookies = dict(request.COOKIES)

    # --- QUERY ---
    query_params = dict(request.GET)

    # --- USER ---
    user = request.user
    user_data = None
    if hasattr(user, "is_authenticated") and user.is_authenticated:
        user_data = {
            "id": getattr(user, "id", None),
            "username": getattr(user, "username", None),
            "email": getattr(user, "email", None),
        }

    return {
        "method": request.method,
        "path": request.path,
        "full_path": request.get_full_path(),
        "absolute_uri": request.build_absolute_uri(),
        "host": request.get_host(),
        "scheme": request.scheme,
        "ip": _get_client_ip(request),
        "headers": headers,
        "meta": meta,
        "query_params": query_params,
        "cookies": cookies,
        "body": body,
        "user": user_data,
    }


def _get_client_ip(request):
    xff = request.META.get("HTTP_X_FORWARDED_FOR")
    if xff:
        return xff.split(",")[0].strip()
    return request.META.get("REMOTE_ADDR")
# middlewares.py

from audit_context import set_audit_context
from request_snapshot import build_request_snapshot


class AuditRequestMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):

        snapshot = build_request_snapshot(request)

        set_audit_context(snapshot)

        return self.get_response(request)

Don't forget to register the middleware in your settings.py

# my_signals.py

from django.db.models.signals import post_save
from django.dispatch import receiver

from models import OTP
from audit_context import get_audit_context


@receiver(post_save, sender=OTP)
def otp_post_save(sender, instance, created, **kwargs):

    ctx = get_audit_context()

    print("\n===== AUDIT LOG =====")
    print(ctx)
    print("=====================\n")

    if created:
        print("OTP CREATED")
    else:
        print("OTP UPDATED")

And don't forget to register your signals, as usual.

Replace the OTP model with the model you are working it, I just used that for testing.

You can always add more data to the return data of the build_request_snapshot function as needed.

What this does:

  • Request comes in.

  • It builds the snapshot ans save it

  • The request-response flow continues.

  • The snapshot is available for us in your signals

Вернуться на верх