Logging is one of those things you don’t think about—until you really, really need it. If you want to log every single request and response in your Django Rest Framework (DRF) APIs—including errors, user info, and performance metrics—this guide will show you a robust, production-ready logging setup you can use in any project.
Here’s how it works, what I learned, and how you can do it too.
Why Log All DRF Requests and Responses?
- Debugging: See exactly what data is coming in and out.
- Auditing: Track who did what, when, and from where.
- Performance: Measure response times and spot slow endpoints.
- Security: Catch suspicious or unauthorized activity.
The Solution: Middleware FTW
A custom Django middleware can:
- Log every DRF request and response (including errors)
- Add a unique request ID for correlation
- Log the authenticated user ID (or “anonymous”)
- Measure execution time and log the start timestamp
- Emit logs as structured JSON (easy to parse and analyze)
The Core Middleware (Simplified)
import logging
import json
import time
import uuid
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger("drf.request_response")
@dataclass
class APILogEntry:
id: str
user_id: str
method: str
path: str
trace_id: str = None
start_timestamp: str = None
status: int = None
elapsed_ms: int = None
request_body: str = None
response_body: str = None
exception: str = None
def get_user_identifier(request):
user = getattr(request, "user", None)
if user and getattr(user, "is_authenticated", False) and getattr(user, "id", None):
return str(user.id)
return "anonymous"
def get_trace_id(request):
context = getattr(request, "context", None)
if context and isinstance(context, dict):
return context.get("trace_id")
return None
class DRFRequestResponseLoggingMiddleware(MiddlewareMixin):
def process_request(self, request):
if request.path.startswith("/api/"):
request._drf_logging_start = time.monotonic()
request._drf_logging_id = str(uuid.uuid4())
request._drf_logging_user = get_user_identifier(request)
request._drf_logging_start_timestamp = datetime.now(timezone.utc).isoformat()
try:
request._drf_logging_body = request.body.decode("utf-8")
except Exception:
request._drf_logging_body = "<unreadable>"
def process_response(self, request, response):
if request.path.startswith("/api/"):
start = getattr(request, "_drf_logging_start", None)
req_id = getattr(request, "_drf_logging_id", "-")
user = getattr(request, "_drf_logging_user", get_user_identifier(request))
body = getattr(request, "_drf_logging_body", "<unreadable>")
trace_id = get_trace_id(request)
start_timestamp = getattr(request, "_drf_logging_start_timestamp", None)
elapsed_ms = int((time.monotonic() - start) * 1000) if start else None
try:
content = response.content.decode("utf-8")
except Exception:
content = "<unreadable>"
log_entry = APILogEntry(
id=req_id,
user_id=user,
method=request.method,
path=request.path,
trace_id=trace_id,
start_timestamp=start_timestamp,
status=response.status_code,
elapsed_ms=elapsed_ms,
request_body=body,
response_body=content,
)
logger.info(json.dumps(asdict(log_entry)))
return response
def process_exception(self, request, exception):
if request.path.startswith("/api/"):
req_id = getattr(request, "_drf_logging_id", "-")
user = getattr(request, "_drf_logging_user", get_user_identifier(request))
body = getattr(request, "_drf_logging_body", "<unreadable>")
trace_id = get_trace_id(request)
start_timestamp = getattr(request, "_drf_logging_start_timestamp", None)
log_entry = APILogEntry(
id=req_id,
user_id=user,
method=getattr(request, "method", "-"),
path=getattr(request, "path", "-"),
trace_id=trace_id,
start_timestamp=start_timestamp,
request_body=body,
exception=str(exception),
)
logger.error(json.dumps(asdict(log_entry)))
Setting Up Django Logging
To actually capture and view the logs from your custom logger (drf.request_response
), you can extend Django’s default logging configuration in your settings.py
file. Here’s a robust example that uses a custom formatter and only shows these logs in production (not in development):
LOGGING = DEFAULT_LOGGING.copy()
LOGGING["formatters"]["drf.request_response"] = {
"format": "[{levelname}] {name} {message}",
"style": "{",
}
LOGGING["handlers"]["drf.request_response"] = {
"level": "INFO",
"filters": ["require_debug_false"], # this will not show in dev
"class": "logging.StreamHandler",
"formatter": "drf.request_response",
}
LOGGING["loggers"]["drf.request_response"] = {
"handlers": ["drf.request_response"],
"level": "INFO",
"propagate": False,
}
- This configuration copies Django’s
DEFAULT_LOGGING
and adds a custom formatter for your API logs. - The handler uses the
require_debug_false
filter, so these logs will only appear whenDEBUG = False
(i.e., in production, not development). - The log format is simple and readable, but you can adjust it as needed.
- You can further customize handlers to log to files, external services, or other destinations.
Lessons Learned
- Always use a dedicated logger (e.g.,
drf.request_response
) so you can filter and export just your API logs. - Emit logs as JSON for easy parsing and analysis.
- Include a unique request ID to correlate requests and responses.
- Log user ID and path for full context.
- Log the start timestamp and elapsed time for performance monitoring.
- Be careful with sensitive data—never log passwords or secrets.
- Keep logs manageable—avoid logging large files or binary data.
Conclusion
Adding comprehensive logging to your DRF APIs is a simple step that pays off immensely for debugging, monitoring, and auditing. With a custom middleware and a dedicated logger, you gain full visibility into every API call—who made it, what was sent, how long it took, and what the response was. This setup not only helps you catch issues early but also makes your API more robust and production-ready.
Have you implemented API logging in your Django projects? Do you have tips, questions, or stories to share? Drop a comment below—let’s learn from each other and make our APIs even better!