Spaces:
Configuration error
Configuration error
| """ | |
| 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 ───────────────────────────────────────────────────────────── | |
| 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 = {} | |
| 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}) | |