"""External API logs listing with filters and summary.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import List, Optional from fastapi import APIRouter, Depends, Query from sqlalchemy import cast, case, func, Integer from sqlalchemy.orm import Session from app.database import get_db from app.models.external_api_log import ExternalApiLog from app.schemas.external_api_log import ExternalApiLogResponse, ExternalApiLogSummaryItem from app.utils.auth import get_current_user from app.config import get_settings router = APIRouter(prefix="/api/external-api-logs", tags=["external_api_logs"]) @router.get("", response_model=List[ExternalApiLogResponse]) def list_external_api_logs( direction: Optional[str] = Query(None), target_type: Optional[str] = Query(None), target_id: Optional[int] = Query(None), method: Optional[str] = Query(None), success: Optional[bool] = Query(None), status_code: Optional[int] = Query(None), path: Optional[str] = Query(None), limit: int = Query(100, le=500), offset: int = Query(0), db: Session = Depends(get_db), _=Depends(get_current_user), ): q = db.query(ExternalApiLog) if direction: q = q.filter(ExternalApiLog.direction == direction) if target_type: q = q.filter(ExternalApiLog.target_type == target_type) if target_id is not None: q = q.filter(ExternalApiLog.target_id == target_id) if method and isinstance(method, str): q = q.filter(ExternalApiLog.method == method.upper()) if success is not None: q = q.filter(ExternalApiLog.success == success) if status_code is not None: q = q.filter(ExternalApiLog.status_code == status_code) if path: q = q.filter(ExternalApiLog.path.like(f"%{path}%")) logs = q.order_by(ExternalApiLog.created_at.desc()).offset(offset).limit(limit).all() return logs @router.get("/count") def count_external_api_logs( direction: Optional[str] = Query(None), target_type: Optional[str] = Query(None), target_id: Optional[int] = Query(None), method: Optional[str] = Query(None), success: Optional[bool] = Query(None), status_code: Optional[int] = Query(None), path: Optional[str] = Query(None), db: Session = Depends(get_db), _=Depends(get_current_user), ): q = db.query(ExternalApiLog) if direction: q = q.filter(ExternalApiLog.direction == direction) if target_type: q = q.filter(ExternalApiLog.target_type == target_type) if target_id is not None: q = q.filter(ExternalApiLog.target_id == target_id) if method and isinstance(method, str): q = q.filter(ExternalApiLog.method == method.upper()) if success is not None: q = q.filter(ExternalApiLog.success == success) if status_code is not None: q = q.filter(ExternalApiLog.status_code == status_code) if path: q = q.filter(ExternalApiLog.path.like(f"%{path}%")) return {"total": q.count()} @router.get("/summary", response_model=List[ExternalApiLogSummaryItem]) def get_external_api_log_summary( slow_ms: Optional[int] = Query(None), hours: int = Query(24, ge=1, le=720), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: Session = Depends(get_db), _=Depends(get_current_user), ): """Return aggregated summary grouped by direction + target + method + path. - hours: time window (default 24, max 720). - limit: max rows returned (default 50, max 200). - offset: pagination offset (default 0). """ settings = get_settings() slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms q = db.query(*_summary_cols(slow_threshold)) cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) q = q.filter(ExternalApiLog.created_at >= cutoff) q = q.group_by( ExternalApiLog.direction, ExternalApiLog.target_type, ExternalApiLog.target_id, ExternalApiLog.target_name, ExternalApiLog.method, ExternalApiLog.path, ).order_by(func.sum(ExternalApiLog.duration_ms).desc()) q = q.offset(offset).limit(limit) rows = q.all() return [ ExternalApiLogSummaryItem( direction=row.direction, target_type=row.target_type, target_id=row.target_id, target_name=row.target_name, method=row.method, path=row.path, call_count=row.call_count, success_count=row.success_count or 0, fail_count=row.fail_count or 0, avg_duration_ms=round(float(row.avg_duration_ms or 0), 1), max_duration_ms=row.max_duration_ms or 0, slow_count=row.slow_count or 0, ) for row in rows ] @router.get("/summary/count") def count_external_api_log_summary( slow_ms: Optional[int] = Query(None), hours: int = Query(24, ge=1, le=720), db: Session = Depends(get_db), _=Depends(get_current_user), ): """Return the number of distinct aggregated summary groups. Uses the same grouping as /summary, returns the count of groups (not the count of raw log rows). """ q = db.query( ExternalApiLog.direction, ExternalApiLog.target_type, ExternalApiLog.target_id, ExternalApiLog.target_name, ExternalApiLog.method, ExternalApiLog.path, ) cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) q = q.filter(ExternalApiLog.created_at >= cutoff) q = q.group_by( ExternalApiLog.direction, ExternalApiLog.target_type, ExternalApiLog.target_id, ExternalApiLog.target_name, ExternalApiLog.method, ExternalApiLog.path, ) return {"total": q.count()} def _summary_cols(slow_threshold: int) -> list: return [ ExternalApiLog.direction, ExternalApiLog.target_type, ExternalApiLog.target_id, ExternalApiLog.target_name, ExternalApiLog.method, ExternalApiLog.path, func.count(ExternalApiLog.id).label("call_count"), func.sum(cast(ExternalApiLog.success, Integer)).label("success_count"), func.sum(cast(1 - cast(ExternalApiLog.success, Integer), Integer)).label("fail_count"), func.avg(ExternalApiLog.duration_ms).label("avg_duration_ms"), func.max(ExternalApiLog.duration_ms).label("max_duration_ms"), func.sum(case((ExternalApiLog.duration_ms >= slow_threshold, 1), else_=0)).label("slow_count"), ] def clean_expired_logs(db: Session) -> int: """Delete external API logs older than the configured retention period. Returns the number of deleted rows. """ settings = get_settings() cutoff = datetime.now(timezone.utc) - timedelta(days=settings.external_api_log_retention_days) deleted = db.query(ExternalApiLog).filter( ExternalApiLog.created_at < cutoff ).delete(synchronize_session=False) if deleted: db.commit() return deleted