doomalaysocreate / debug_log.py
deploy-space action
deploy 8a94cf6 (socreate)
8168d5f
Raw
History Blame Contribute Delete
5.56 kB
"""Debug logging system — structured logs to stderr + in-memory buffer.
Every critical function logs its entry, exit, duration, and errors. Logs are
written to:
1. stderr (HF Space logs capture this automatically)
2. In-memory ring buffer (for /api/debug/logs endpoint)
3. SSE subscriber queues (for /api/debug/stream real-time streaming)
View logs in the browser via GET /api/debug/logs?cat=glm&tail=50
Stream logs in real-time via GET /api/debug/stream (SSE, auth-gated)
(gated by the rotation token or CRITIQUE_TOKEN env var).
"""
from __future__ import annotations
import datetime
import json
import os
import queue
import sys
from collections import deque
from typing import Any
# --- config -------------------------------------------------------------------
_MAX_MEMORY_LOGS = 5000
_STDERR_ENABLED = os.environ.get("DOOMALAYSOCREATE_LOG", "1").strip() not in ("0", "false", "no", "")
# --- in-memory ring buffer ----------------------------------------------------
_memory_logs: deque[dict] = deque(maxlen=_MAX_MEMORY_LOGS)
_categories: set[str] = set()
# --- SSE subscriber queues ----------------------------------------------------
_sse_queues: list[queue.Queue] = []
def subscribe() -> queue.Queue:
"""Register an SSE subscriber queue. Returns a Queue that receives new log records."""
q: queue.Queue = queue.Queue()
_sse_queues.append(q)
return q
def unsubscribe(q: queue.Queue) -> None:
"""Remove a subscriber queue."""
try:
_sse_queues.remove(q)
except ValueError:
pass
def _notify_subscribers(record: dict) -> None:
"""Push a log record to all SSE subscriber queues (best-effort)."""
dead: list[queue.Queue] = []
for q in _sse_queues:
try:
q.put_nowait(record)
except Exception:
dead.append(q)
for q in dead:
try:
_sse_queues.remove(q)
except ValueError:
pass
# --- sensitive field patterns for auto-redaction ----------------------------
_SENSITIVE_PATTERNS = [
(r"\b(token|secret|key|password|auth)\s*[:=]\S+", "[REDACTED]"),
(r"\b(Bearer\s+)[A-Za-z0-9-_.]+", r"\1[REDACTED]"),
(r"\b(api[_-]?key)\s*[:=]\S+", r"\1=[REDACTED]"),
]
def _iso_ts() -> str:
now = datetime.datetime.now(datetime.timezone.utc)
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
def _redact(msg: str) -> str:
import re
for pattern, replacement in _SENSITIVE_PATTERNS:
msg = re.sub(pattern, replacement, msg, flags=re.IGNORECASE)
return msg
def log_event(category: str, **fields: Any) -> None:
"""Fire-and-forget: never raises. Observability must not break a request."""
try:
record = {
"ts": _iso_ts(),
"cat": category,
"fields": {k: _redact(str(v)) for k, v in fields.items()},
}
_memory_logs.append(record)
_categories.add(category)
if _STDERR_ENABLED:
print(json.dumps(record, ensure_ascii=False), file=sys.stderr, flush=True)
_notify_subscribers(record)
except Exception:
pass # never break the calling code
def log_entry(level: str = "INFO", cat: str = "", fn: str = "",
msg: str = "", data: dict | None = None,
ms: float | None = None) -> None:
"""Log a structured entry in the frontend-compatible format.
This is the primary logging call used by the debug screen. Parameters
match the LogEntry interface in DebugScreen.tsx:
- level: ERROR / WARN / INFO / DEBUG
- cat: category (startup, glm, auth, agent, conscious, errors, http)
- fn: calling function name
- msg: human-readable message
- data: optional structured payload (shown as expandable JSON)
- ms: optional elapsed milliseconds for timed operations
"""
try:
record = {
"ts": _iso_ts(),
"level": level,
"cat": cat,
"fn": fn,
"msg": msg,
"data": data or {},
}
if ms is not None:
record["ms"] = ms
_memory_logs.append(record)
_categories.add(cat)
if _STDERR_ENABLED:
print(json.dumps(record, ensure_ascii=False), file=sys.stderr, flush=True)
_notify_subscribers(record)
except Exception:
pass
def get_recent_logs(category: str | None = None,
tail: int = 100,
min_level: str | None = None) -> list[dict]:
"""Return recent log entries, optionally filtered by category and level."""
all_logs = list(_memory_logs)
if category:
all_logs = [r for r in all_logs if r.get("cat") == category]
if min_level:
_LEVEL_WEIGHT = {"ERROR": 40, "WARN": 30, "INFO": 20, "DEBUG": 10}
threshold = _LEVEL_WEIGHT.get(min_level.upper(), 0)
all_logs = [r for r in all_logs if _LEVEL_WEIGHT.get(r.get("level", "INFO"), 0) >= threshold]
return all_logs[-tail:]
def list_log_categories() -> list[str]:
return sorted(_categories)
def clear_logs(category: str | None = None) -> None:
"""Clear the in-memory ring buffer (optionally by category)."""
global _memory_logs, _categories
if category:
_memory_logs = deque(
(r for r in _memory_logs if r.get("cat") != category),
maxlen=_MAX_MEMORY_LOGS,
)
remaining = set(r.get("cat", "") for r in _memory_logs)
_categories = remaining
else:
_memory_logs.clear()
_categories.clear()