alphax/app/db/system_logs.py
2026-05-16 15:45:59 +08:00

210 lines
6.4 KiB
Python

"""System error log storage."""
from __future__ import annotations
import hashlib
import json
import os
import socket
import traceback
from datetime import datetime, timedelta
from app.db.schema import get_conn
def _now() -> str:
return datetime.now().isoformat()
def _truncate(value, limit: int) -> str:
text = str(value or "")
return text[:limit]
def _json(value) -> str:
try:
return json.dumps(value or {}, ensure_ascii=False, default=str)
except Exception:
return "{}"
def _fingerprint(error_type: str, message: str, stack_trace: str, path: str = "") -> str:
basis = "\n".join([
str(error_type or ""),
str(message or "")[:500],
str(path or ""),
str(stack_trace or "").splitlines()[-12:][0] if stack_trace else "",
])
return hashlib.sha256(basis.encode("utf-8", errors="ignore")).hexdigest()[:32]
def record_system_error(
*,
source: str,
level: str = "error",
message: str = "",
error_type: str = "",
stack_trace: str = "",
request_method: str = "",
request_path: str = "",
query_string: str = "",
user_email: str = "",
user_id: int = 0,
status_code: int = 500,
context: dict | None = None,
fingerprint: str = "",
) -> int:
"""Persist a system error. Logging must never raise into the caller."""
try:
error_type = error_type or "Error"
stack_trace = stack_trace or ""
fingerprint = fingerprint or _fingerprint(error_type, message, stack_trace, request_path)
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO system_error_log (
created_at, level, source, error_type, message, stack_trace,
request_method, request_path, query_string, user_email, user_id,
status_code, fingerprint, context_json, host, pid
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
_now(),
_truncate(level or "error", 20),
_truncate(source or "app", 80),
_truncate(error_type, 160),
_truncate(message, 2000),
_truncate(stack_trace, 60000),
_truncate(request_method, 16),
_truncate(request_path, 500),
_truncate(query_string, 1000),
_truncate(user_email, 255),
int(user_id or 0),
int(status_code or 0),
fingerprint,
_json(context),
_truncate(socket.gethostname(), 120),
int(os.getpid() or 0),
),
)
log_id = row.fetchone()["id"]
conn.commit()
return int(log_id)
finally:
conn.close()
except Exception:
return 0
def record_exception(exc: BaseException, *, source: str, context: dict | None = None, **kwargs) -> int:
return record_system_error(
source=source,
error_type=exc.__class__.__name__,
message=str(exc),
stack_trace="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
context=context or {},
**kwargs,
)
def list_system_errors(
*,
offset: int = 0,
limit: int = 50,
level: str = "",
source: str = "",
search: str = "",
hours: int = 168,
) -> dict:
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
where = []
params = []
if level and level != "all":
where.append("level=%s")
params.append(level)
if source and source != "all":
where.append("source=%s")
params.append(source)
if search:
like = f"%{search.strip()}%"
where.append("(message ILIKE %s OR error_type ILIKE %s OR request_path ILIKE %s OR user_email ILIKE %s)")
params.extend([like, like, like, like])
if hours and int(hours) > 0:
cutoff = (datetime.now() - timedelta(hours=int(hours))).isoformat()
where.append("created_at >= %s")
params.append(cutoff)
clause = "WHERE " + " AND ".join(where) if where else ""
conn = get_conn()
try:
total = conn.execute(f"SELECT COUNT(*) AS n FROM system_error_log {clause}", tuple(params)).fetchone()["n"]
rows = conn.execute(
f"""
SELECT id, created_at, level, source, error_type, message, request_method,
request_path, user_email, user_id, status_code, fingerprint, host, pid
FROM system_error_log
{clause}
ORDER BY id DESC
LIMIT %s OFFSET %s
""",
tuple(params + [limit, offset]),
).fetchall()
return {
"items": [dict(row) for row in rows],
"total": int(total or 0),
"limit": limit,
"offset": offset,
"has_more": offset + limit < int(total or 0),
}
finally:
conn.close()
def get_system_error(log_id: int) -> dict | None:
conn = get_conn()
try:
row = conn.execute("SELECT * FROM system_error_log WHERE id=%s", (int(log_id),)).fetchone()
if not row:
return None
item = dict(row)
try:
item["context"] = json.loads(item.get("context_json") or "{}")
except Exception:
item["context"] = {}
return item
finally:
conn.close()
def get_system_error_stats(hours: int = 24) -> dict:
cutoff = (datetime.now() - timedelta(hours=int(hours or 24))).isoformat()
conn = get_conn()
try:
rows = conn.execute(
"""
SELECT level, source, COUNT(*) AS n
FROM system_error_log
WHERE created_at >= %s
GROUP BY level, source
ORDER BY n DESC
""",
(cutoff,),
).fetchall()
latest = conn.execute(
"""
SELECT created_at, level, source, error_type, message
FROM system_error_log
ORDER BY id DESC LIMIT 1
"""
).fetchone()
return {
"hours": int(hours or 24),
"total": sum(int(row["n"] or 0) for row in rows),
"groups": [dict(row) for row in rows],
"latest": dict(latest) if latest else None,
}
finally:
conn.close()