210 lines
6.4 KiB
Python
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()
|