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,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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user