"""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()