Files
LogHive/backend/app/services/analytics_service.py
T
2026-05-09 14:55:14 +08:00

126 lines
4.1 KiB
Python

"""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())