Terminal / api /structured_log.py
Pulka
fix: _logger at correct module level in 6 backend files (retry) — crash fix
0423856 verified
Raw
History Blame
8.63 kB
"""
backend/api/structured_log.py — Gap 2.2: Logging strutturato JSON + /api/logs endpoint.
Design:
- JsonFormatter: ogni record → singola riga JSON su stdout/stderr (Railway/HF logs)
- LogRingHandler: ring buffer deque(maxlen=500) in-memory per query recenti
- GET /api/logs — query con filtri level/source/limit/since_ms/q
- POST /api/logs/frontend — riceve JS error report dal frontend
- log_event() — helper per log strutturato diretto
- setup_structured_logging() — da chiamare una sola volta in main.py al boot
Thread-safety: deque.append è GIL-safe in CPython. Snapshot via list() prima di iterare.
Performance: zero I/O, zero lock — ring handler è fire-and-forget.
"""
from __future__ import annotations
import json
import time
from collections import deque
from typing import Any
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import logging
_logger = logging.getLogger("agente_ai") # S-BUGFIX
router = APIRouter()
# ── Ring buffer ─────────────────────────────────────────────────────────────
_LOG_RING: deque[dict] = deque(maxlen=500)
# Campi standard del LogRecord — non duplicarli come extra
_STANDARD_FIELDS = frozenset({
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName",
"processName", "process", "message", "taskName",
})
class JsonFormatter(logging.Formatter):
"""Emette ogni record come JSON a singola riga su stdout/stderr."""
def format(self, record: logging.LogRecord) -> str:
entry: dict[str, Any] = {
"ts": round(record.created * 1000),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"file": f"{record.filename}:{record.lineno}",
}
if record.exc_info:
entry["exc"] = self.formatException(record.exc_info)
for key, val in vars(record).items():
if key not in _STANDARD_FIELDS and not key.startswith("_") and key not in entry:
try:
json.dumps(val)
entry[key] = val
except (TypeError, ValueError):
entry[key] = str(val)
return json.dumps(entry, default=str, ensure_ascii=False)
class LogRingHandler(logging.Handler):
"""Scrive ogni record nel ring buffer _LOG_RING (fire-and-forget, mai solleva)."""
def emit(self, record: logging.LogRecord) -> None:
try:
entry: dict[str, Any] = {
"ts": round(record.created * 1000),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"file": f"{record.filename}:{record.lineno}",
}
if record.exc_info:
entry["exc"] = logging.Formatter().formatException(record.exc_info)
for key, val in vars(record).items():
if key not in _STANDARD_FIELDS and not key.startswith("_") and key not in entry:
try:
json.dumps(val)
entry[key] = val
except (TypeError, ValueError):
entry[key] = str(val)
_LOG_RING.append(entry)
except Exception as _exc:
_logger.debug("[structured_log] silenced %s", type(_exc).__name__) # noqa: BLE001
def setup_structured_logging() -> None:
"""
Configura logging strutturato JSON sull'intero albero root.
Da chiamare UNA SOLA VOLTA in main.py prima di qualsiasi import di router.
Sostituisce il basicConfig plain-text esistente.
Output:
- Console (stderr): JSON a riga singola → leggibile da Railway logs / HF logs
- Ring buffer: ultimi 500 record → interrogabile via GET /api/logs
"""
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers.clear()
# Console handler: JSON su stderr
console_h = logging.StreamHandler()
console_h.setFormatter(JsonFormatter())
console_h.setLevel(logging.INFO)
root.addHandler(console_h)
# Ring handler: buffer in-memory per /api/logs
ring_h = LogRingHandler()
ring_h.setLevel(logging.DEBUG)
root.addHandler(ring_h)
# Silenzia librerie rumorose
for _noisy in ("httpx", "httpcore", "uvicorn.access", "asyncio", "hpack"):
logging.getLogger(_noisy).setLevel(logging.WARNING)
logging.getLogger("agente_ai").info(
"structured_log: JSON logging attivo ✓",
extra={"source": "boot", "ring_maxlen": 500},
)
def log_event(level: str, source: str, msg: str, **kwargs: Any) -> None:
"""
Helper per log strutturato diretto con source esplicita.
Esempio:
log_event("error", "agent", "Tool call failed", tool="exec_code", task_id="abc")
"""
logger = logging.getLogger(f"agente_ai.{source}")
extra = {"source": source, **kwargs}
log_fn = getattr(logger, level.lower(), logger.info)
log_fn(msg, extra=extra)
# ── GET /api/logs ─────────────────────────────────────────────────────────────
@router.get("/api/logs")
async def get_logs(
level: str | None = Query(None, description="Filtra livello: DEBUG/INFO/WARNING/ERROR/CRITICAL"),
source: str | None = Query(None, description="Filtra logger name (substring match)"),
limit: int = Query(100, ge=1, le=500),
since_ms: int | None = Query(None, description="Solo log dopo questo timestamp Unix ms"),
q: str | None = Query(None, description="Ricerca testo libero nel campo msg"),
) -> JSONResponse:
"""
GET /api/logs — query log recenti dal ring buffer in-memory.
Nessuna autenticazione richiesta (dati aggregati, zero PII).
Response: { ok, count, total, logs: [{ts, level, logger, msg, file, ...}] }
"""
logs = list(_LOG_RING) # snapshot GIL-safe
if since_ms is not None:
logs = [e for e in logs if e.get("ts", 0) >= since_ms]
if level:
lvl_upper = level.upper()
logs = [e for e in logs if e.get("level") == lvl_upper]
if source:
src_lower = source.lower()
logs = [e for e in logs if src_lower in (e.get("logger") or "").lower()]
if q:
q_lower = q.lower()
logs = [e for e in logs if q_lower in (e.get("msg") or "").lower()]
# Più recenti prima, poi tronca
logs = logs[-limit:][::-1]
return JSONResponse({
"ok": True,
"count": len(logs),
"total": len(_LOG_RING),
"logs": logs,
})
# ── POST /api/logs/frontend ──────────────────────────────────────────────────
class FrontendLogEntry(BaseModel):
level: str # "error" | "warn" | "info"
msg: str
source: str = "frontend"
url: str | None = None
stack: str | None = None
ts_ms: int | None = None
extra: dict = {}
@router.post("/api/logs/frontend")
async def post_frontend_log(entry: FrontendLogEntry) -> JSONResponse:
"""
POST /api/logs/frontend — riceve JS error report dal frontend.
Inserisce nel ring buffer con logger="frontend.<source>".
Non richiede auth (dati diagnostici, zero PII utente).
"""
_LEVEL_MAP = {"error": "ERROR", "warn": "WARNING", "info": "INFO", "debug": "DEBUG"}
level_str = _LEVEL_MAP.get(entry.level.lower(), "ERROR")
record: dict[str, Any] = {
"ts": entry.ts_ms or round(time.time() * 1000),
"level": level_str,
"logger": f"frontend.{entry.source}",
"msg": entry.msg[:500],
"file": entry.url or "—",
}
if entry.stack:
record["exc"] = entry.stack[:800]
if entry.extra:
for k, v in entry.extra.items():
if k not in record:
record[k] = v
_LOG_RING.append(record)
# Echo nel logger Python → visibile nei Railway/HF logs
logging.getLogger(f"frontend.{entry.source}").log(
getattr(logging, level_str, logging.ERROR),
entry.msg[:200],
extra={"source": entry.source, "url": entry.url or ""},
)
return JSONResponse({"ok": True})