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,142 @@
|
||||
"""Alert evaluation and notification service."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.alert import AlertRule, AlertHistory, AlertLevel, AlertOperator
|
||||
from app.models.log import LogEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlertService:
|
||||
"""Evaluates alert rules against recent log data and triggers notifications."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def evaluate_rules(self) -> list[AlertHistory]:
|
||||
"""Check all enabled alert rules and trigger any that match."""
|
||||
result = await self.db.execute(
|
||||
select(AlertRule).where(AlertRule.is_enabled == True)
|
||||
)
|
||||
rules: list[AlertRule] = result.scalars().all()
|
||||
triggered: list[AlertHistory] = []
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
hit = await self._evaluate_single(rule)
|
||||
if hit:
|
||||
triggered.append(hit)
|
||||
except Exception as e:
|
||||
logger.error("Error evaluating rule %s: %s", rule.id, e)
|
||||
|
||||
return triggered
|
||||
|
||||
async def _evaluate_single(self, rule: AlertRule) -> Optional[AlertHistory]:
|
||||
"""Evaluate a single rule. Returns AlertHistory if triggered."""
|
||||
now = datetime.now(timezone.utc)
|
||||
since = now - timedelta(minutes=rule.window_minutes)
|
||||
|
||||
stmt = (
|
||||
select(func.count(LogEntry.id))
|
||||
.where(LogEntry.project_id == rule.project_id)
|
||||
.where(LogEntry.timestamp >= since)
|
||||
.where(LogEntry.level == rule.level.value)
|
||||
)
|
||||
count = (await self.db.execute(stmt)).scalar() or 0
|
||||
|
||||
operator_map = {
|
||||
AlertOperator.GT: lambda v, t: v > t,
|
||||
AlertOperator.GTE: lambda v, t: v >= t,
|
||||
AlertOperator.LT: lambda v, t: v < t,
|
||||
AlertOperator.LTE: lambda v, t: v <= t,
|
||||
AlertOperator.EQ: lambda v, t: v == t,
|
||||
}
|
||||
|
||||
op_fn = operator_map.get(rule.operator)
|
||||
if op_fn and op_fn(float(count), rule.threshold):
|
||||
history = AlertHistory(
|
||||
rule_id=rule.id,
|
||||
project_id=rule.project_id,
|
||||
level=rule.level,
|
||||
message=(
|
||||
f"Alert '{rule.name}': {rule.level.value} logs count ({count}) "
|
||||
f"{rule.operator.value} threshold ({rule.threshold}) "
|
||||
f"in last {rule.window_minutes} min"
|
||||
),
|
||||
triggered_value=float(count),
|
||||
)
|
||||
self.db.add(history)
|
||||
await self.db.flush()
|
||||
logger.info("Alert triggered: %s", history.message)
|
||||
|
||||
if rule.notify_channels:
|
||||
await self._notify(rule, history)
|
||||
|
||||
return history
|
||||
|
||||
return None
|
||||
|
||||
async def _notify(self, rule: AlertRule, history: AlertHistory) -> None:
|
||||
"""Send notification via configured channels."""
|
||||
channels = [c.strip() for c in rule.notify_channels.split(",") if c.strip()]
|
||||
for channel in channels:
|
||||
if channel == "feishu" and settings.FEISHU_WEBHOOK_URL:
|
||||
await self._send_feishu(rule, history)
|
||||
elif channel == "dingtalk" and settings.DINGTALK_WEBHOOK_URL:
|
||||
await self._send_dingtalk(rule, history)
|
||||
|
||||
async def _send_feishu(self, rule: AlertRule, history: AlertHistory) -> None:
|
||||
"""Send a notification to Feishu webhook."""
|
||||
import httpx
|
||||
|
||||
payload = {
|
||||
"msg_type": "post",
|
||||
"content": {
|
||||
"post": {
|
||||
"zh_cn": {
|
||||
"title": f"[LogHive] Alert: {rule.name}",
|
||||
"content": [
|
||||
[{"tag": "text", "text": history.message}],
|
||||
[
|
||||
{
|
||||
"tag": "text",
|
||||
"text": f"Project: {rule.project_id} | Level: {rule.level.value}",
|
||||
}
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(settings.FEISHU_WEBHOOK_URL, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error("Feishu notification failed: %s", e)
|
||||
|
||||
async def _send_dingtalk(self, rule: AlertRule, history: AlertHistory) -> None:
|
||||
"""Send a notification to DingTalk webhook."""
|
||||
import httpx
|
||||
|
||||
payload = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": (
|
||||
f"[LogHive] Alert: {rule.name}\n"
|
||||
f"{history.message}\n"
|
||||
f"Project: {rule.project_id} | Level: {rule.level.value}"
|
||||
)
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(settings.DINGTALK_WEBHOOK_URL, json=payload, timeout=10)
|
||||
except Exception as e:
|
||||
logger.error("DingTalk notification failed: %s", e)
|
||||
@@ -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