Initial commit: LogHive centralized log management system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
v6ole
2026-05-09 14:55:14 +08:00
commit abfd07331e
54 changed files with 3816 additions and 0 deletions
View File
+180
View File
@@ -0,0 +1,180 @@
"""Alert management API routes."""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.alert import AlertRule, AlertHistory, AlertLevel, AlertOperator
from app.schemas.alert import (
AlertRuleCreate,
AlertRuleUpdate,
AlertRuleResponse,
AlertHistoryResponse,
)
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
# ── Rule CRUD ──────────────────────────────────────────────────
@router.post("/rules", response_model=AlertRuleResponse, status_code=201)
async def create_alert_rule(
data: AlertRuleCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new alert rule."""
rule = AlertRule(
project_id=data.project_id,
name=data.name,
level=data.level,
field=data.field,
operator=data.operator,
threshold=data.threshold,
window_minutes=data.window_minutes,
notify_channels=",".join(data.notify_channels) if data.notify_channels else "",
)
db.add(rule)
await db.flush()
await db.refresh(rule)
return _rule_to_response(rule)
@router.get("/rules", response_model=List[AlertRuleResponse])
async def list_alert_rules(
project_id: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""List alert rules, optionally filtered by project."""
stmt = select(AlertRule)
if project_id:
stmt = stmt.where(AlertRule.project_id == project_id)
stmt = stmt.order_by(AlertRule.created_at.desc())
result = await db.execute(stmt)
return [_rule_to_response(r) for r in result.scalars().all()]
@router.get("/rules/{rule_id}", response_model=AlertRuleResponse)
async def get_alert_rule(rule_id: str, db: AsyncSession = Depends(get_db)):
"""Get a single alert rule."""
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="Alert rule not found")
return _rule_to_response(rule)
@router.patch("/rules/{rule_id}", response_model=AlertRuleResponse)
async def update_alert_rule(
rule_id: str,
data: AlertRuleUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update an alert rule."""
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="Alert rule not found")
if data.name is not None:
rule.name = data.name
if data.level is not None:
rule.level = data.level
if data.field is not None:
rule.field = data.field
if data.operator is not None:
rule.operator = data.operator
if data.threshold is not None:
rule.threshold = data.threshold
if data.window_minutes is not None:
rule.window_minutes = data.window_minutes
if data.is_enabled is not None:
rule.is_enabled = data.is_enabled
if data.notify_channels is not None:
rule.notify_channels = ",".join(data.notify_channels)
await db.flush()
await db.refresh(rule)
return _rule_to_response(rule)
@router.delete("/rules/{rule_id}", status_code=204)
async def delete_alert_rule(rule_id: str, db: AsyncSession = Depends(get_db)):
"""Delete an alert rule."""
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="Alert rule not found")
await db.delete(rule)
# ── Alert History ──────────────────────────────────────────────
@router.get("/history", response_model=List[AlertHistoryResponse])
async def list_alert_history(
project_id: Optional[str] = Query(None),
rule_id: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
"""List alert trigger history."""
stmt = select(AlertHistory).order_by(AlertHistory.triggered_at.desc()).limit(limit)
if project_id:
stmt = stmt.where(AlertHistory.project_id == project_id)
if rule_id:
stmt = stmt.where(AlertHistory.rule_id == rule_id)
result = await db.execute(stmt)
return [_history_to_response(h) for h in result.scalars().all()]
@router.post("/history/{history_id}/acknowledge", response_model=AlertHistoryResponse)
async def acknowledge_alert(
history_id: str,
db: AsyncSession = Depends(get_db),
):
"""Mark an alert as acknowledged."""
result = await db.execute(select(AlertHistory).where(AlertHistory.id == history_id))
history = result.scalar_one_or_none()
if not history:
raise HTTPException(status_code=404, detail="Alert history not found")
history.is_acknowledged = True
await db.flush()
await db.refresh(history)
return _history_to_response(history)
# ── Helpers ────────────────────────────────────────────────────
def _rule_to_response(rule: AlertRule) -> AlertRuleResponse:
return AlertRuleResponse(
id=rule.id,
project_id=rule.project_id,
name=rule.name,
level=rule.level.value if hasattr(rule.level, "value") else rule.level,
field=rule.field,
operator=rule.operator.value if hasattr(rule.operator, "value") else rule.operator,
threshold=rule.threshold,
window_minutes=rule.window_minutes,
is_enabled=rule.is_enabled,
notify_channels=rule.notify_channels.split(",") if rule.notify_channels else [],
created_at=rule.created_at,
)
def _history_to_response(history: AlertHistory) -> AlertHistoryResponse:
return AlertHistoryResponse(
id=history.id,
rule_id=history.rule_id,
project_id=history.project_id,
level=history.level.value if hasattr(history.level, "value") else history.level,
message=history.message,
triggered_value=history.triggered_value,
is_acknowledged=history.is_acknowledged,
triggered_at=history.triggered_at,
)
+34
View File
@@ -0,0 +1,34 @@
"""Dashboard API routes."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.analytics_service import AnalyticsService
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/stats")
async def dashboard_stats(
project: Optional[str] = Query(None),
hours: int = Query(24, ge=1, le=720),
db: AsyncSession = Depends(get_db),
):
"""Get overview statistics for the dashboard."""
service = AnalyticsService(db)
return await service.get_dashboard_stats(project=project, hours=hours)
@router.get("/timeseries")
async def dashboard_timeseries(
project: Optional[str] = Query(None),
hours: int = Query(24, ge=1, le=720),
interval: str = Query("5m", max_length=16),
db: AsyncSession = Depends(get_db),
):
"""Get time-series data for chart rendering."""
service = AnalyticsService(db)
return await service.get_time_series(project=project, hours=hours, interval=interval)
+253
View File
@@ -0,0 +1,253 @@
"""Log ingestion and query API routes."""
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy import func, select, delete, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, to_display_time, from_query_time
from app.database import get_db
from app.schemas.log import (
LogBatchRequest,
LogEntry as LogEntrySchema,
LogQueryParams,
LogSearchResult,
LogStatsResult,
)
from app.models.project import Project
from app.models.log import LogEntry
from app.core.auth import verify_api_key, resolve_project_by_api_key
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/logs", tags=["logs"])
# ── Ingestion ─────────────────────────────────────────────────
@router.post("/ingest", status_code=201)
async def ingest_logs(
batch: LogBatchRequest,
authorization: Optional[str] = Header(None),
db: AsyncSession = Depends(get_db),
):
"""Receive a batch of log entries from a project."""
api_key = await verify_api_key(authorization)
project = await resolve_project_by_api_key(db, api_key)
now = datetime.now(timezone.utc)
rows = []
for entry in batch.entries:
row = LogEntry(
project_id=project.id,
project_name=project.name,
timestamp=entry.timestamp or now,
level=entry.level,
logger=entry.logger,
message=entry.message,
module=entry.module,
function=entry.function,
line_no=entry.line_no,
trace_id=entry.trace_id,
exception=entry.exception,
extra=entry.extra,
)
rows.append(row)
db.add_all(rows)
await db.flush()
return {"accepted": len(rows), "project": project.name}
@router.post("/ingest/single", status_code=201)
async def ingest_single_log(
entry: LogEntrySchema,
project_name: str = Query(..., description="Project name"),
authorization: Optional[str] = Header(None),
db: AsyncSession = Depends(get_db),
):
"""Receive a single log entry (useful for testing / simple clients)."""
api_key = await verify_api_key(authorization)
project = await resolve_project_by_api_key(db, api_key)
now = datetime.now(timezone.utc)
row = LogEntry(
project_id=project.id,
project_name=project.name,
timestamp=entry.timestamp or now,
level=entry.level,
logger=entry.logger,
message=entry.message,
module=entry.module,
function=entry.function,
line_no=entry.line_no,
trace_id=entry.trace_id,
exception=entry.exception,
extra=entry.extra,
)
db.add(row)
await db.flush()
return {"accepted": 1, "project": project.name}
# ── Query ──────────────────────────────────────────────────────
@router.get("/search", response_model=LogSearchResult)
async def search_logs(
project: Optional[str] = Query(None, description="Filter by project name"),
level: Optional[str] = Query(None, pattern="^(debug|info|warning|error|critical)$"),
query: Optional[str] = Query(None, max_length=1024),
trace_id: Optional[str] = Query(None, max_length=64),
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=1000),
sort_by: str = Query("timestamp", max_length=32),
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db),
):
"""Search logs with full-text query and filters."""
conditions = []
if project:
conditions.append(LogEntry.project_name == project)
if level:
conditions.append(LogEntry.level == level)
if trace_id:
conditions.append(LogEntry.trace_id == trace_id)
if start_time:
conditions.append(LogEntry.timestamp >= from_query_time(start_time))
if end_time:
conditions.append(LogEntry.timestamp <= from_query_time(end_time))
if query:
conditions.append(
func.to_tsvector(
"english",
func.concat(
LogEntry.message, " ",
func.coalesce(LogEntry.logger, ""), " ",
func.coalesce(LogEntry.module, ""),
),
).bool_op("@@")(
func.plainto_tsquery("english", query)
)
)
# Sort
sort_col = getattr(LogEntry, sort_by, LogEntry.timestamp)
order = sort_col.desc() if sort_order == "desc" else sort_col.asc()
# Count total
count_stmt = select(func.count(LogEntry.id))
if conditions:
count_stmt = count_stmt.where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# Fetch page
stmt = select(LogEntry).order_by(order).offset((page - 1) * page_size).limit(page_size)
if conditions:
stmt = stmt.where(and_(*conditions))
rows = (await db.execute(stmt)).scalars().all()
hits = []
for r in rows:
hits.append({
"project_id": r.project_id,
"project_name": r.project_name,
"timestamp": to_display_time(r.timestamp),
"level": r.level,
"logger": r.logger,
"message": r.message,
"module": r.module,
"function": r.function,
"line_no": r.line_no,
"trace_id": r.trace_id,
"exception": r.exception,
"extra": r.extra,
})
return LogSearchResult(total=total, page=page, page_size=page_size, hits=hits)
@router.get("/stats", response_model=LogStatsResult)
async def log_stats(
project: Optional[str] = Query(None),
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
interval: str = Query("5m", max_length=16),
db: AsyncSession = Depends(get_db),
):
"""Get aggregated log statistics over a time range."""
conditions = []
if project:
conditions.append(LogEntry.project_name == project)
if start_time:
conditions.append(LogEntry.timestamp >= from_query_time(start_time))
if end_time:
conditions.append(LogEntry.timestamp <= from_query_time(end_time))
def apply(stmt):
if conditions:
stmt = stmt.where(and_(*conditions))
return stmt
# Time buckets (hourly)
stmt = apply(
select(
func.date_trunc("hour", LogEntry.timestamp).label("bucket"),
func.count(LogEntry.id).label("cnt"),
).group_by("bucket").order_by("bucket")
)
time_rows = (await db.execute(stmt)).all()
time_buckets = [
{"key": to_display_time(r.bucket), "doc_count": r.cnt}
for r in time_rows
]
# Level counts
stmt = apply(
select(LogEntry.level, func.count(LogEntry.id).label("cnt"))
.group_by(LogEntry.level)
)
level_rows = (await db.execute(stmt)).all()
level_counts = {r.level: r.cnt for r in level_rows}
# Top loggers
stmt = apply(
select(LogEntry.logger, func.count(LogEntry.id).label("cnt"))
.group_by(LogEntry.logger)
.order_by(func.count(LogEntry.id).desc())
.limit(20)
)
logger_rows = (await db.execute(stmt)).all()
top_loggers = [{"logger": r.logger, "count": r.cnt} for r in logger_rows]
# Total count
total = (await db.execute(apply(select(func.count(LogEntry.id))))).scalar() or 0
return LogStatsResult(
project=project,
time_buckets=time_buckets,
level_counts=level_counts,
top_loggers=top_loggers,
total=total,
)
@router.delete("/cleanup")
async def cleanup_old_logs(
db: AsyncSession = Depends(get_db),
):
"""Delete log entries older than the retention period."""
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.LOG_RETENTION_DAYS)
result = await db.execute(
delete(LogEntry).where(LogEntry.timestamp < cutoff)
)
await db.flush()
return {"deleted_count": result.rowcount}
+123
View File
@@ -0,0 +1,123 @@
"""Project management API routes."""
import uuid
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectWithToken
router = APIRouter(prefix="/api/projects", tags=["projects"])
@router.post("", response_model=ProjectWithToken, status_code=201)
async def create_project(
data: ProjectCreate,
db: AsyncSession = Depends(get_db),
):
"""Register a new project for log collection."""
existing = await db.execute(select(Project).where(Project.name == data.name))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Project '{data.name}' already exists",
)
project = Project(
name=data.name,
description=data.description,
api_key=uuid.uuid4().hex,
)
db.add(project)
await db.flush()
await db.refresh(project)
return project
@router.get("", response_model=List[ProjectResponse])
async def list_projects(
db: AsyncSession = Depends(get_db),
):
"""List all registered projects."""
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
return result.scalars().all()
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""Get a single project by ID."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.patch("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
data: ProjectUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if data.name is not None:
# Check uniqueness
existing = await db.execute(
select(Project).where(Project.name == data.name, Project.id != project_id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Name already taken")
project.name = data.name
if data.description is not None:
project.description = data.description
if data.is_active is not None:
project.is_active = data.is_active
project.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(project)
return project
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""Delete a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
@router.post("/{project_id}/rotate-key", response_model=ProjectWithToken)
async def rotate_api_key(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""Rotate a project's API key."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.api_key = uuid.uuid4().hex
project.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(project)
return project