Exception logging in FastAPI with request_id + structured context
When your FastAPI app crashes, you need to know which request caused it. Without proper context, exception logs become a pile of stack traces with no way to trace them back to specific user actions.
The Problem: Lost Context in Errors
Default FastAPI exception handling logs the stack trace but loses the request context:
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
# This exception loses all request context
raise ValueError(f"User {user_id} not found")
When this crashes, you see:
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "...", line 42, in __call__
...
ValueError: User 123 not found
What’s missing:
Which request triggered this error?
What was the URL path?
Who made the request?
How do I correlate this with other logs from the same request?
The Solution: Automatic Context Preservation
fapilog’s middleware automatically preserves request context in error logs:
from fastapi import FastAPI
from fapilog.fastapi import FastAPIBuilder
app = FastAPI(
lifespan=FastAPIBuilder()
.with_preset("fastapi")
.build()
)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
raise ValueError(f"User {user_id} not found")
Now when the same exception occurs, fapilog logs:
{
"timestamp": "2026-01-21T10:30:00.123Z",
"level": "ERROR",
"message": "request_failed",
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"path": "/users/123",
"method": "GET",
"status_code": 500,
"error_type": "ValueError",
"error": "User 123 not found",
"latency_ms": 12.5
}
Every field you need to debug the issue is right there.
Custom Exception Handlers
For custom exception handling, use get_request_logger to maintain context:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fapilog.fastapi import FastAPIBuilder, get_request_logger
app = FastAPI(
lifespan=FastAPIBuilder()
.with_preset("fastapi")
.build()
)
class UserNotFoundError(Exception):
def __init__(self, user_id: int):
self.user_id = user_id
super().__init__(f"User {user_id} not found")
@app.exception_handler(UserNotFoundError)
async def handle_user_not_found(request: Request, exc: UserNotFoundError):
logger = await get_request_logger(request)
await logger.warning(
"user_not_found",
user_id=exc.user_id,
path=request.url.path,
)
return JSONResponse(
status_code=404,
content={"error": "User not found", "user_id": exc.user_id},
)
The logger automatically includes the request_id from the current request context.
Structured Error Fields
When fapilog captures an exception, it includes these structured fields:
Field |
Description |
|---|---|
|
Exception class name (e.g., |
|
Exception message ( |
|
Correlation ID for the request |
|
Request URL path |
|
HTTP method (GET, POST, etc.) |
|
HTTP response status |
|
Request duration in milliseconds |
For full stack trace capture, pass exc_info=True:
try:
process_payment(order)
except PaymentError as e:
await logger.error(
"payment_failed",
order_id=order.id,
exc_info=True, # Captures full traceback
)
raise
This adds the exception field with detailed traceback info:
{
"level": "ERROR",
"message": "payment_failed",
"request_id": "abc-123",
"order_id": "order-456",
"exception": {
"error.type": "PaymentError",
"error.message": "Card declined",
"error.stack": "Traceback (most recent call last):\n ...",
"error.frames": [
{"file": "payment.py", "line": 42, "function": "charge", "code": "..."}
]
}
}
Going Deeper
FastAPI request_id Logging - How request context propagates
Context Binding Reference - Manual context management
Context Enrichment - How enrichers work
Why Fapilog? - How fapilog compares to other logging libraries