DH
4 min read

Structured Logging and Observability for the Django + FastAPI + Next.js Stack

Production-ready observability pattern with JSON logging, request correlation IDs, and queryable logs across frontend and backend—turning 3-hour debugging sessions into 20-minute fixes.

Structured Logging and Observability for the Django + FastAPI + Next.js Stack

Most logging setups are an afterthought—scattered print() calls, inconsistent levels, zero correlation between frontend and backend. When something breaks at 2 AM, that's the difference between a 20-minute fix and a 3-hour archaeology project.

This guide shows you a production-ready observability pattern across all three layers: Next.js, Django, and FastAPI. We'll use structured JSON logging, request correlation IDs, and make logs queryable in any sink (Loki, Datadog, CloudWatch).


The Core Principle: Structured Over Human-Readable

Log structured data, not strings. Instead of:

ERROR 2024-01-15 user login failed for [email protected]

Log:

{
"level": "error",
"event": "user_login_failed",
"user_email": "[email protected]",
"timestamp": "2024-01-15T14:23:01Z",
"trace_id": "a3f9c1d2e4b5",
"service": "django-api",
"duration_ms": 142
}

Every field is filterable. You can query level=error AND service=django-api AND duration_ms > 500 and get real answers.


Step 1: Correlation IDs — The Glue Across Services

A correlation ID (trace ID) is a UUID generated at the edge—frontend or API gateway—and forwarded through every downstream call. Without it, linking a frontend error to a backend exception is guesswork.

Next.js: Generate and forward

In middleware.ts:

import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';

export function middleware(request: Request) {
const traceId = request.headers.get('x-trace-id') ?? uuidv4();
const response = NextResponse.next();
response.headers.set('x-trace-id', traceId);
return response;
}

In your fetch utility:

const res = await fetch(`${process.env.API_URL}/endpoint`, {
headers: {
'x-trace-id': traceId,
'Content-Type': 'application/json',
},
});

Step 2: Django — Structured Logging with python-json-logger

Install:

pip install python-json-logger

In settings.py:

LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'json': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'format': '%(asctime)s %(levelname)s %(name)s %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'json',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}

Add middleware to extract and bind the trace ID:

import logging
import threading

_local = threading.local()

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

def __call__(self, request):
trace_id = request.headers.get('X-Trace-Id', '')
_local.trace_id = trace_id
response = self.get_response(request)
response['X-Trace-Id'] = trace_id
return response

class TraceFilter(logging.Filter):
def filter(self, record):
record.trace_id = getattr(_local, 'trace_id', '')
return True

Add TraceFilter to your handler and TraceIDMiddleware to MIDDLEWARE. Every Django log now carries the trace ID.


Step 3: FastAPI — Structlog for Async Logging

FastAPI's async model makes thread-locals unreliable. Use structlog with context variables:

pip install structlog
import structlog
from contextvars import ContextVar
from fastapi import FastAPI, Request

trace_id_var: ContextVar[str] = ContextVar('trace_id', default='')

structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
]
)

app = FastAPI()

@app.middleware("http")
async def trace_middleware(request: Request, call_next):
trace_id = request.headers.get("x-trace-id", "")
trace_id_var.set(trace_id)
response = await call_next(request)
return response

def get_logger():
return structlog.get_logger().bind(
trace_id=trace_id_var.get(),
service="fastapi-service"
)

Use in routes:

@app.get("/items/{item_id}")
async def get_item(item_id: int):
log = get_logger()
log.info("item_fetch", item_id=item_id)

Step 4: Frontend Error Logging from Next.js

Client errors need to reach your logging endpoint:

// lib/logger.ts
export async function logClientError(error: Error, context: Record<string, unknown> = {}) {
await fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
level: 'error',
event: 'client_error',
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
...context,
}),
});
}

Your /api/log handler writes this to stdout as JSON. Your log aggregator picks it up alongside backend logs—same structure, fully queryable.


Tying It Together

A single user-facing failure now produces correlated JSON lines across all services, all sharing the same trace_id. You can query that ID and reconstruct the full chain—frontend click, API call, database query, error—in order.

The implementation cost is minimal. The operational payoff when debugging production incidents is substantial. Structured logging isn't sophisticated observability; it's foundational. Get it in early.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps