refactor: rename package fabledassistant -> scribe (code-only)
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""Application logging service for audit, usage, and error events."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback as tb_module
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select, text
|
||||
|
||||
from scribe.config import Config
|
||||
from scribe.models import async_session
|
||||
from scribe.models.app_log import AppLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_retention_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def log_audit(
|
||||
action: str,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="audit",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
ip_address=ip_address,
|
||||
details=json.dumps(details) if details else None,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def log_usage(
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
status_code: int | None = None,
|
||||
duration_ms: float | None = None,
|
||||
) -> None:
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="usage",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
status_code=status_code,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def log_error(
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
traceback: str | None = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if error_type:
|
||||
details["error_type"] = error_type
|
||||
if error_message:
|
||||
details["error_message"] = error_message
|
||||
if traceback:
|
||||
details["traceback"] = traceback
|
||||
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="error",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
details=json.dumps(details) if details else None,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_logs(
|
||||
category: str | None = None,
|
||||
user_id: int | None = None,
|
||||
search: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
async with async_session() as session:
|
||||
query = select(AppLog)
|
||||
count_query = select(func.count(AppLog.id))
|
||||
|
||||
if category:
|
||||
query = query.where(AppLog.category == category)
|
||||
count_query = count_query.where(AppLog.category == category)
|
||||
if user_id is not None:
|
||||
query = query.where(AppLog.user_id == user_id)
|
||||
count_query = count_query.where(AppLog.user_id == user_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
search_filter = (
|
||||
AppLog.action.ilike(pattern)
|
||||
| AppLog.endpoint.ilike(pattern)
|
||||
| AppLog.username.ilike(pattern)
|
||||
| AppLog.details.ilike(pattern)
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
if date_from:
|
||||
query = query.where(AppLog.created_at >= date_from)
|
||||
count_query = count_query.where(AppLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(AppLog.created_at <= date_to)
|
||||
count_query = count_query.where(AppLog.created_at <= date_to)
|
||||
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = query.order_by(AppLog.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(query)
|
||||
logs = [row.to_dict() for row in result.scalars().all()]
|
||||
|
||||
return logs, total
|
||||
|
||||
|
||||
async def get_log_stats() -> dict:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT category, COUNT(*) as count FROM app_logs GROUP BY category"
|
||||
)
|
||||
)
|
||||
stats = {row.category: row.count for row in result}
|
||||
return {
|
||||
"audit": stats.get("audit", 0),
|
||||
"usage": stats.get("usage", 0),
|
||||
"error": stats.get("error", 0),
|
||||
"total": sum(stats.values()),
|
||||
}
|
||||
|
||||
|
||||
async def delete_old_logs(retention_days: int) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
delete(AppLog).where(AppLog.created_at < cutoff)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def _retention_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
try:
|
||||
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
|
||||
if deleted:
|
||||
logger.info("Log retention: deleted %d old log entries", deleted)
|
||||
except Exception:
|
||||
logger.exception("Error in log retention cleanup")
|
||||
|
||||
|
||||
def start_log_retention_loop() -> None:
|
||||
global _retention_task
|
||||
if _retention_task is None or _retention_task.done():
|
||||
_retention_task = asyncio.create_task(_retention_loop())
|
||||
Reference in New Issue
Block a user