"""Log analytics service — provides aggregated statistics and insights.""" import logging from datetime import datetime, timezone, timedelta from typing import Optional from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.config import to_display_time from app.models.log import LogEntry logger = logging.getLogger(__name__) class AnalyticsService: """Time-series analytics over log data.""" def __init__(self, db: AsyncSession): self.db = db def _build_filters(self, project: Optional[str], since: datetime): """Build SQLAlchemy filter list.""" filters = [LogEntry.timestamp >= since] if project: filters.append(LogEntry.project_name == project) return filters async def get_dashboard_stats( self, project: Optional[str] = None, hours: int = 24 ) -> dict: """Get overview statistics for the dashboard.""" now = datetime.now(timezone.utc) since = now - timedelta(hours=hours) filters = self._build_filters(project, since) # Total log count total = (await self.db.execute( select(func.count(LogEntry.id)).where(*filters) )).scalar() or 0 # Error count error_count = (await self.db.execute( select(func.count(LogEntry.id)).where( *filters, LogEntry.level.in_(["error", "critical"]) ) )).scalar() or 0 # Level breakdown level_rows = (await self.db.execute( select(LogEntry.level, func.count(LogEntry.id)) .where(*filters) .group_by(LogEntry.level) )).all() level_breakdown = {row[0]: row[1] for row in level_rows} # Error rate error_rate = round((error_count / total * 100), 2) if total > 0 else 0 # Top error messages top_rows = (await self.db.execute( select(LogEntry.message, func.count(LogEntry.id).label("cnt")) .where(*filters, LogEntry.level.in_(["error", "critical"])) .group_by(LogEntry.message) .order_by(func.count(LogEntry.id).desc()) .limit(10) )).all() top_errors = [{"message": row[0], "count": row[1]} for row in top_rows] return { "total_logs": total, "error_count": error_count, "error_rate": error_rate, "level_breakdown": level_breakdown, "time_range_hours": hours, "top_errors": top_errors, } async def get_time_series( self, project: Optional[str] = None, hours: int = 24, interval: str = "5m", ) -> list[dict]: """Get time-series data for log volume charts.""" now = datetime.now(timezone.utc) since = now - timedelta(hours=hours) n = int("".join(c for c in interval if c.isdigit()) or "5") unit = "".join(c for c in interval if c.isalpha()) or "m" if unit == "h": n = n * 60 n = max(n, 1) # Build WHERE clauses where_clauses = [f"timestamp >= '{since.isoformat()}'"] params: dict = {} if project: where_clauses.append("project_name = :project") params["project"] = project where_sql = " AND ".join(where_clauses) sql = text( f"SELECT " f" date_trunc('hour', timestamp) + " f" ((floor(extract(minute from timestamp) / {n})::int * {n}) || ' min')::interval AS bucket, " f" level, " f" count(*) AS cnt " f"FROM log_entries " f"WHERE {where_sql} " f"GROUP BY bucket, level " f"ORDER BY bucket" ) rows = (await self.db.execute(sql, params)).all() buckets: dict[str, dict] = {} for row in rows: ts = to_display_time(row.bucket) if isinstance(row.bucket, datetime) else str(row.bucket) if ts not in buckets: buckets[ts] = {"timestamp": ts, "total": 0, "levels": {}} buckets[ts]["total"] += row.cnt buckets[ts]["levels"][row.level] = row.cnt return list(buckets.values())