Initial commit: LogHive centralized log management system
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user