"""Application logging service for audit, usage, and error events.""" import asyncio import json import logging import traceback as tb_module from datetime import datetime, timedelta, timezone from sqlalchemy import delete, func, select, text from fabledassistant.config import Config from fabledassistant.models import async_session from fabledassistant.models.app_log import AppLog logger = logging.getLogger(__name__) _retention_task: asyncio.Task | None = None async def log_audit( action: str, user_id: int | None = None, username: str | None = None, ip_address: str | None = None, details: dict | None = None, ) -> None: async with async_session() as session: log = AppLog( category="audit", user_id=user_id, username=username, action=action, ip_address=ip_address, details=json.dumps(details) if details else None, ) session.add(log) await session.commit() async def log_usage( user_id: int | None = None, username: str | None = None, endpoint: str | None = None, method: str | None = None, status_code: int | None = None, duration_ms: float | None = None, ) -> None: async with async_session() as session: log = AppLog( category="usage", user_id=user_id, username=username, endpoint=endpoint, method=method, status_code=status_code, duration_ms=duration_ms, ) session.add(log) await session.commit() async def log_generation( user_id: int, conv_id: int, model: str, timing: dict, ) -> None: """Persist per-generation timing breakdown to app_logs for benchmarking.""" async with async_session() as session: log = AppLog( category="usage", user_id=user_id, action="generation", endpoint=f"/chat/conversations/{conv_id}", duration_ms=timing.get("total_ms"), details=json.dumps({"model": model, **timing}), ) session.add(log) await session.commit() async def log_error( user_id: int | None = None, username: str | None = None, endpoint: str | None = None, method: str | None = None, error_type: str | None = None, error_message: str | None = None, traceback: str | None = None, ) -> None: details = {} if error_type: details["error_type"] = error_type if error_message: details["error_message"] = error_message if traceback: details["traceback"] = traceback async with async_session() as session: log = AppLog( category="error", user_id=user_id, username=username, endpoint=endpoint, method=method, details=json.dumps(details) if details else None, ) session.add(log) await session.commit() async def get_logs( category: str | None = None, user_id: int | None = None, search: str | None = None, date_from: str | None = None, date_to: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[dict], int]: async with async_session() as session: query = select(AppLog) count_query = select(func.count(AppLog.id)) if category: query = query.where(AppLog.category == category) count_query = count_query.where(AppLog.category == category) if user_id is not None: query = query.where(AppLog.user_id == user_id) count_query = count_query.where(AppLog.user_id == user_id) if search: pattern = f"%{search}%" search_filter = ( AppLog.action.ilike(pattern) | AppLog.endpoint.ilike(pattern) | AppLog.username.ilike(pattern) | AppLog.details.ilike(pattern) ) query = query.where(search_filter) count_query = count_query.where(search_filter) if date_from: query = query.where(AppLog.created_at >= date_from) count_query = count_query.where(AppLog.created_at >= date_from) if date_to: query = query.where(AppLog.created_at <= date_to) count_query = count_query.where(AppLog.created_at <= date_to) total = (await session.execute(count_query)).scalar() or 0 query = query.order_by(AppLog.created_at.desc()).limit(limit).offset(offset) result = await session.execute(query) logs = [row.to_dict() for row in result.scalars().all()] return logs, total async def get_log_stats() -> dict: async with async_session() as session: result = await session.execute( text( "SELECT category, COUNT(*) as count FROM app_logs GROUP BY category" ) ) stats = {row.category: row.count for row in result} return { "audit": stats.get("audit", 0), "usage": stats.get("usage", 0), "error": stats.get("error", 0), "total": sum(stats.values()), } async def delete_old_logs(retention_days: int) -> int: cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) async with async_session() as session: result = await session.execute( delete(AppLog).where(AppLog.created_at < cutoff) ) await session.commit() return result.rowcount async def _retention_loop() -> None: while True: await asyncio.sleep(3600) # hourly try: deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS) if deleted: logger.info("Log retention: deleted %d old log entries", deleted) except Exception: logger.exception("Error in log retention cleanup") def start_log_retention_loop() -> None: global _retention_task if _retention_task is None or _retention_task.done(): _retention_task = asyncio.create_task(_retention_loop())