abfd07331e
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
126 lines
4.1 KiB
Python
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())
|