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.

424 lines
13 KiB
Python

"""
Error log API endpoints
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import case, select, desc, and_, or_, func
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.error_log import ErrorLog
from app.schemas.error_log import (
ErrorLogResponse,
ErrorLogListResponse,
ErrorLogStats,
ErrorLogUpdate
)
router = APIRouter()
ALLOWED_SORT_FIELDS = {"created_at", "status_code", "response_time_ms"}
@router.get(
"/logs",
response_model=ErrorLogListResponse,
summary="Get error logs",
description="""
Retrieve error logs with filtering and pagination options.
**Filters:**
- Date range (start_date, end_date)
- Error type
- Status code range
- Endpoint pattern
- Resolution status
**Sorting:**
- By date (newest first by default)
- By status code
- By response time
**Pagination:**
- Configurable page size (default: 50, max: 200)
- Page-based navigation
"""
)
async def get_error_logs(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
error_type: Optional[str] = Query(None, description="Filter by error type"),
status_code: Optional[int] = Query(None, description="Filter by status code"),
endpoint: Optional[str] = Query(None, description="Filter by endpoint (supports wildcards)"),
is_resolved: Optional[bool] = Query(None, description="Filter by resolution status"),
sort_by: str = Query("created_at", description="Sort field: created_at, status_code, response_time_ms"),
sort_order: str = Query("desc", description="Sort order: asc or desc"),
db: AsyncSession = Depends(get_db)
):
"""Get paginated error logs with filters"""
# Build query
query = select(ErrorLog)
# Apply filters
filters = []
if start_date:
filters.append(ErrorLog.created_at >= start_date)
if end_date:
filters.append(ErrorLog.created_at <= end_date)
if error_type:
filters.append(ErrorLog.error_type == error_type)
if status_code:
filters.append(ErrorLog.status_code == status_code)
if endpoint:
# Support wildcard matching
if '*' in endpoint:
pattern = endpoint.replace('*', '%')
filters.append(ErrorLog.endpoint.like(pattern))
else:
filters.append(ErrorLog.endpoint == endpoint)
if is_resolved is not None:
filters.append(ErrorLog.is_resolved == is_resolved)
if filters:
query = query.where(and_(*filters))
# Apply sorting (validate sort_by against allowed fields)
if sort_by not in ALLOWED_SORT_FIELDS:
sort_by = "created_at"
sort_column = getattr(ErrorLog, sort_by, ErrorLog.created_at)
if sort_order.lower() == "desc":
query = query.order_by(desc(sort_column))
else:
query = query.order_by(sort_column)
# Get total count
count_query = select(func.count()).select_from(ErrorLog)
if filters:
count_query = count_query.where(and_(*filters))
result = await db.execute(count_query)
total_count = result.scalar()
# Apply pagination
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
# Execute query
result = await db.execute(query)
error_logs = result.scalars().all()
# Calculate pagination info
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0
return ErrorLogListResponse(
items=[log.to_dict() for log in error_logs],
total=total_count,
page=page,
page_size=page_size,
total_pages=total_pages
)
@router.get(
"/logs/{log_id}",
response_model=ErrorLogResponse,
summary="Get error log by ID",
description="Retrieve detailed information about a specific error log"
)
async def get_error_log(
log_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get specific error log by ID"""
result = await db.execute(
select(ErrorLog).where(ErrorLog.id == log_id)
)
error_log = result.scalar_one_or_none()
if not error_log:
raise HTTPException(
status_code=404,
detail=f"Error log with ID {log_id} not found"
)
return ErrorLogResponse(**error_log.to_dict())
@router.get(
"/by-request/{request_id}",
response_model=ErrorLogResponse,
summary="Get error log by request ID",
description="Retrieve error log information for a specific request ID"
)
async def get_error_by_request_id(
request_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get error log by request ID"""
result = await db.execute(
select(ErrorLog).where(ErrorLog.request_id == request_id).order_by(desc(ErrorLog.created_at))
)
error_log = result.scalar_one_or_none()
if not error_log:
# Return 404 but it's ok if no error exists for this request
raise HTTPException(
status_code=404,
detail=f"No error log found for request ID {request_id}"
)
return ErrorLogResponse(**error_log.to_dict())
@router.get(
"/stats",
response_model=ErrorLogStats,
summary="Get error statistics",
description="""
Get aggregated statistics about errors.
**Statistics include:**
- Total error count
- Errors by type
- Errors by status code
- Errors by endpoint
- Time-based trends
- Resolution rate
"""
)
async def get_error_stats(
start_date: Optional[datetime] = Query(None, description="Start date for statistics"),
end_date: Optional[datetime] = Query(None, description="End date for statistics"),
db: AsyncSession = Depends(get_db)
):
"""Get error statistics"""
# Default to last 7 days if no dates provided
if not end_date:
end_date = datetime.now(timezone.utc)
if not start_date:
start_date = end_date - timedelta(days=7)
# Build base filter
date_filter = and_(
ErrorLog.created_at >= start_date,
ErrorLog.created_at <= end_date
)
# Get total, resolved count, and avg response time in a single query
summary_result = await db.execute(
select(
func.count().label('total'),
func.count(case((ErrorLog.is_resolved == True, 1))).label('resolved'),
func.avg(case((ErrorLog.response_time_ms.isnot(None), ErrorLog.response_time_ms))).label('avg_time'),
).select_from(ErrorLog).where(date_filter)
)
summary_row = summary_result.one()
total_errors = summary_row.total
resolved_errors = summary_row.resolved
avg_response_time = summary_row.avg_time or 0
# Get errors by type
type_result = await db.execute(
select(
ErrorLog.error_type,
func.count().label('count')
).where(date_filter)
.group_by(ErrorLog.error_type)
.order_by(desc('count'))
.limit(10)
)
errors_by_type = {row.error_type: row.count for row in type_result}
# Get errors by status code
status_result = await db.execute(
select(
ErrorLog.status_code,
func.count().label('count')
).where(date_filter)
.group_by(ErrorLog.status_code)
.order_by(desc('count'))
.limit(10)
)
errors_by_status = {str(row.status_code): row.count for row in status_result}
# Get errors by endpoint (top 10)
endpoint_result = await db.execute(
select(
ErrorLog.endpoint,
func.count().label('count')
).where(date_filter)
.group_by(ErrorLog.endpoint)
.order_by(desc('count'))
.limit(10)
)
errors_by_endpoint = {row.endpoint: row.count for row in endpoint_result}
# Get hourly trend for last 24 hours if within range
hourly_trend = {}
if (end_date - start_date).days <= 1:
# Dialect-aware date formatting
dialect_name = db.bind.dialect.name if db.bind else "sqlite"
if dialect_name == "postgresql":
hour_expr = func.to_char(ErrorLog.created_at, 'YYYY-MM-DD HH24:00').label('hour')
else:
hour_expr = func.strftime('%Y-%m-%d %H:00', ErrorLog.created_at).label('hour')
hourly_result = await db.execute(
select(
hour_expr,
func.count().label('count')
).where(date_filter)
.group_by('hour')
.order_by('hour')
)
hourly_trend = {row.hour: row.count for row in hourly_result}
return ErrorLogStats(
total_errors=total_errors,
resolved_errors=resolved_errors,
unresolved_errors=total_errors - resolved_errors,
resolution_rate=(resolved_errors / total_errors * 100) if total_errors > 0 else 0,
errors_by_type=errors_by_type,
errors_by_status_code=errors_by_status,
errors_by_endpoint=errors_by_endpoint,
average_response_time_ms=avg_response_time,
hourly_trend=hourly_trend,
start_date=start_date.isoformat(),
end_date=end_date.isoformat()
)
@router.patch(
"/logs/{log_id}",
response_model=ErrorLogResponse,
summary="Update error log",
description="Update error log resolution status and notes"
)
async def update_error_log(
log_id: int,
update_data: ErrorLogUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update error log (mark as resolved, add notes, etc.)"""
result = await db.execute(
select(ErrorLog).where(ErrorLog.id == log_id)
)
error_log = result.scalar_one_or_none()
if not error_log:
raise HTTPException(
status_code=404,
detail=f"Error log with ID {log_id} not found"
)
# Update fields
if update_data.is_resolved is not None:
error_log.is_resolved = update_data.is_resolved
if update_data.is_resolved:
error_log.resolved_at = datetime.now(timezone.utc)
else:
error_log.resolved_at = None
if update_data.resolution_notes is not None:
error_log.resolution_notes = update_data.resolution_notes
await db.commit()
await db.refresh(error_log)
return ErrorLogResponse(**error_log.to_dict())
@router.delete(
"/logs/old",
summary="Delete old error logs",
description="Delete error logs older than specified days"
)
async def delete_old_logs(
days_old: int = Query(30, ge=1, le=365, description="Delete logs older than this many days"),
only_resolved: bool = Query(True, description="Only delete resolved errors"),
db: AsyncSession = Depends(get_db)
):
"""Delete old error logs"""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_old)
# Build delete query
filters = [ErrorLog.created_at < cutoff_date]
if only_resolved:
filters.append(ErrorLog.is_resolved == True)
# Get count of logs to delete
count_result = await db.execute(
select(func.count()).select_from(ErrorLog).where(and_(*filters))
)
count = count_result.scalar()
# Delete logs
await db.execute(
ErrorLog.__table__.delete().where(and_(*filters))
)
await db.commit()
return {
"message": f"Deleted {count} error logs older than {days_old} days",
"deleted_count": count,
"cutoff_date": cutoff_date.isoformat()
}
@router.delete(
"/logs",
summary="Delete all error logs",
description="Delete all error logs (use with caution)"
)
async def delete_all_error_logs(
confirm: bool = Query(False, description="Must be true to confirm deletion"),
only_resolved: bool = Query(False, description="Only delete resolved errors"),
db: AsyncSession = Depends(get_db)
):
"""Delete all error logs"""
if not confirm:
raise HTTPException(
status_code=400,
detail="Must set confirm=true to delete all logs"
)
# Build delete query
filters = []
if only_resolved:
filters.append(ErrorLog.is_resolved == True)
# Get count of logs to delete
if filters:
count_result = await db.execute(
select(func.count()).select_from(ErrorLog).where(and_(*filters))
)
# Delete with filters
await db.execute(
ErrorLog.__table__.delete().where(and_(*filters))
)
else:
count_result = await db.execute(
select(func.count()).select_from(ErrorLog)
)
# Delete all logs
await db.execute(ErrorLog.__table__.delete())
count = count_result.scalar()
await db.commit()
return {
"message": f"Deleted all {count} error logs" + (" (resolved only)" if only_resolved else ""),
"deleted_count": count
}