""" 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.". 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})