You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

324 lines
13 KiB
Python

"""
Error logging middleware for capturing and storing API errors
"""
import json
import time
import traceback
import uuid
from datetime import datetime, timezone
from typing import Callable, Optional
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from app.core.database import get_db
from app.models.error_log import ErrorLog
from app.models.request_log import RequestLog
import logging
logger = logging.getLogger(__name__)
class ErrorLoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log all API requests and errors to database"""
def __init__(self, app: ASGIApp):
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
"""Process request and log any errors that occur"""
# Generate unique request ID
request_id = str(uuid.uuid4())[:8]
request.state.request_id = request_id
# Track request start time
start_time = time.time()
# Store request details for potential error logging
request_info = await self._extract_request_info(request)
logger.info(f"Processing request {request_id}: {request.method} {request.url.path}")
try:
# Process the request
response = await call_next(request)
# Calculate response time
response_time_ms = (time.time() - start_time) * 1000
# Store response body for error cases
response_body = b""
error_detail = None
# Check if response indicates an error (4xx or 5xx)
if response.status_code >= 400:
logger.info(f"Error response detected: {response.status_code} for request {request_id}")
# Try to capture response body for errors
# We need to consume the response body and recreate it
from starlette.responses import Response
# Collect response body chunks
body_chunks = []
async for chunk in response.body_iterator:
body_chunks.append(chunk)
response_body = b''.join(body_chunks)
# Try to parse as JSON
try:
if response_body:
error_detail = json.loads(response_body.decode('utf-8'))
except Exception as e:
logger.warning(f"Could not parse error response body as JSON: {e}")
# Store raw text if not JSON
try:
error_detail = {"raw_response": response_body.decode('utf-8')}
except:
error_detail = {"raw_response": str(response_body)}
# Log the error with response body
logger.info(f"Logging error for request {request_id}")
await self._log_error(
request_id=request_id,
request_info=request_info,
status_code=response.status_code,
error_detail=error_detail,
response_time_ms=response_time_ms
)
# Recreate response with the same body
response = Response(
content=response_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type
)
# Log all requests (not just errors)
# Add data source info to headers for successful responses
data_source = response.headers.get("X-Data-Source", None)
await self._log_request(
request_id=request_id,
request_info=request_info,
status_code=response.status_code,
response_time_ms=response_time_ms,
response_size=len(response_body) if response_body else None,
data_source=data_source
)
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
return response
except Exception as e:
# Log unexpected errors
response_time_ms = (time.time() - start_time) * 1000
await self._log_error(
request_id=request_id,
request_info=request_info,
status_code=500,
error_type="INTERNAL_SERVER_ERROR",
error_message=str(e),
stack_trace=traceback.format_exc(),
response_time_ms=response_time_ms
)
# Return error response
return JSONResponse(
status_code=500,
content={
"error_type": "INTERNAL_SERVER_ERROR",
"message": "An unexpected error occurred",
"request_id": request_id,
"timestamp": datetime.now(timezone.utc).isoformat()
},
headers={"X-Request-ID": request_id}
)
async def _extract_request_info(self, request: Request) -> dict:
"""Extract request information for logging"""
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
if body_bytes:
body = json.loads(body_bytes.decode('utf-8'))
# Store body for later use in request processing
request._body = body_bytes
except Exception as e:
logger.warning(f"Could not parse request body: {e}")
# Extract headers (remove sensitive ones)
headers = dict(request.headers)
sensitive_headers = ['authorization', 'api-key', 'x-api-key', 'cookie']
for header in sensitive_headers:
if header in headers:
headers[header] = '***REDACTED***'
return {
"endpoint": str(request.url.path),
"method": request.method,
"path": str(request.url),
"query_params": dict(request.query_params) if request.query_params else None,
"request_body": body,
"headers": headers,
"user_agent": headers.get("user-agent"),
"client_ip": request.client.host if request.client else None
}
async def _log_error(
self,
request_id: str,
request_info: dict,
status_code: int,
error_type: Optional[str] = None,
error_message: Optional[str] = None,
error_detail: Optional[dict] = None,
stack_trace: Optional[str] = None,
response_time_ms: Optional[float] = None
):
"""Log error to database"""
logger.info(f"_log_error called for request {request_id}, status {status_code}")
# Skip logging errors for log deletion endpoints to avoid logging the deletion of logs
endpoint = request_info["endpoint"]
method = request_info["method"]
# Don't log errors for DELETE requests to log management endpoints
if (method == "DELETE" and
(endpoint.startswith("/api/v1/admin/requests/logs") or
endpoint.startswith("/api/v1/admin/errors/logs"))):
logger.info(f"Skipping error log for log deletion endpoint: {method} {endpoint}")
return
try:
# Get database session
from app.core.database import AsyncSessionLocal
logger.info(f"Creating database session for request {request_id}")
async with AsyncSessionLocal() as db:
# Extract error information from detail if available
if error_detail and isinstance(error_detail, dict):
if not error_type:
error_type = error_detail.get("error_type", "UNKNOWN_ERROR")
if not error_message:
error_message = error_detail.get("message", "Unknown error occurred")
# Set defaults
if not error_type:
if status_code >= 500:
error_type = "INTERNAL_SERVER_ERROR"
elif status_code >= 400:
error_type = "CLIENT_ERROR"
else:
error_type = "UNKNOWN_ERROR"
if not error_message:
error_message = f"HTTP {status_code} error"
# Create error log entry
error_log = ErrorLog(
request_id=request_id,
endpoint=request_info["endpoint"],
method=request_info["method"],
path=request_info["path"],
query_params=request_info["query_params"],
request_body=request_info["request_body"],
headers=request_info["headers"],
error_type=error_type,
error_message=error_message,
error_detail=error_detail,
status_code=status_code,
stack_trace=stack_trace,
user_agent=request_info["user_agent"],
client_ip=request_info["client_ip"],
response_time_ms=response_time_ms
)
db.add(error_log)
logger.info(f"Error log added to session for request {request_id}")
await db.commit()
logger.info(f"Error log committed to database for request {request_id}")
logger.error(
f"Error logged - Request ID: {request_id}, "
f"Endpoint: {request_info['endpoint']}, "
f"Status: {status_code}, "
f"Error: {error_type} - {error_message}"
)
except Exception as e:
# If we can't log to database, at least log to file
logger.error(f"Failed to log error to database: {e}")
logger.error(
f"Original error - Request ID: {request_id}, "
f"Status: {status_code}, "
f"Error: {error_type} - {error_message}"
)
async def _log_request(
self,
request_id: str,
request_info: dict,
status_code: int,
response_time_ms: Optional[float] = None,
response_size: Optional[int] = None,
data_source: Optional[str] = None
):
"""Log general request to database"""
# Skip logging for log deletion endpoints to avoid logging the deletion of logs
endpoint = request_info["endpoint"]
method = request_info["method"]
# Don't log DELETE requests to log management endpoints
if (method == "DELETE" and
(endpoint.startswith("/api/v1/admin/requests/logs") or
endpoint.startswith("/api/v1/admin/errors/logs"))):
logger.info(f"Skipping log for log deletion endpoint: {method} {endpoint}")
return
try:
# Get database session
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
# Store data source in headers if available
if data_source and request_info.get("headers"):
request_info["headers"]["X-Data-Source"] = data_source
# Create request log entry
request_log = RequestLog(
request_id=request_id,
endpoint=request_info["endpoint"],
method=request_info["method"],
path=request_info["path"],
query_params=request_info["query_params"],
request_body=request_info["request_body"],
headers=request_info["headers"],
status_code=status_code,
response_size=response_size,
user_agent=request_info["user_agent"],
client_ip=request_info["client_ip"],
response_time_ms=response_time_ms
)
db.add(request_log)
await db.commit()
except Exception as e:
# If we can't log to database, at least log to file
logger.error(f"Failed to log request to database: {e}")
logger.info(
f"Request log - Request ID: {request_id}, "
f"Endpoint: {request_info['endpoint']}, "
f"Status: {status_code}, "
f"Response Time: {response_time_ms}ms"
)