Spaces:
Runtime error
Runtime error
| """ | |
| Archivo: events.py | |
| Fecha de modificación: 04/06/2026 | |
| Autor: Equipo AgroVisión | |
| Descripción: | |
| Router de **Telemetría y Observabilidad de UI** (Fase 9). Recibe los eventos que el | |
| frontend emite en cada acción del usuario (`POST /api/events`), los **redacta** | |
| (nunca registra secretos), los escribe en *stdout* (logging estructurado) y los guarda | |
| en un **ring buffer** en memoria (acotado a `MAX_EVENTS`) consultable en | |
| `GET /api/events/recent` — útil para depurar una sesión en vivo sin infraestructura. | |
| Opcionalmente (SF9.3, `EVENTS_PERSIST=true` + `DATABASE_URL`) persiste cada evento en | |
| la tabla `events` de Supabase de forma *best-effort* (un fallo de BD nunca rompe la UI). | |
| Estructura Interna: | |
| - `Event`: esquema Pydantic del evento entrante. | |
| - `_redact`: elimina valores de claves que parezcan secretos. | |
| - `POST /api/events`: ingest (log + buffer + persistencia opcional). | |
| - `GET /api/events/recent`: últimos eventos (filtrables por `session_id`). | |
| Entradas / Dependencias: | |
| - `fastapi`, `pydantic`, `backend.config`; (opcional) `backend.db` para persistir. | |
| Ejemplo de Integración: | |
| from backend.api.events import router | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from collections import deque | |
| from typing import Any | |
| from fastapi import APIRouter, BackgroundTasks, Depends | |
| from pydantic import BaseModel, Field | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from backend.api.deps import get_db | |
| from backend.config import get_settings | |
| router = APIRouter(prefix="/api/events", tags=["telemetría"]) | |
| _logger = logging.getLogger("agrovision.events") | |
| # Tope del buffer en memoria: suficiente para depurar una sesión sin crecer sin límite. | |
| MAX_EVENTS = 500 | |
| _BUFFER: deque[dict[str, Any]] = deque(maxlen=MAX_EVENTS) | |
| # Subcadenas que delatan un secreto en una clave de `meta`. El backend NUNCA confía en | |
| # que el front no mande secretos: si una clave las contiene, su valor se redacta. | |
| _SECRET_KEYS = frozenset( | |
| {"groq", "key", "secret", "token", "password", "anon", "apikey", "auth", "credential"} | |
| ) | |
| class Event(BaseModel): | |
| """Evento de telemetría emitido por la UI (sin secretos).""" | |
| action: str | |
| session_id: str | |
| meta: dict[str, Any] = Field(default_factory=dict) | |
| ts: str | None = None | |
| def _redact(meta: dict[str, Any]) -> dict[str, Any]: | |
| """Reemplaza por '***' el valor de toda clave que parezca un secreto.""" | |
| return {k: ("***" if any(s in k.lower() for s in _SECRET_KEYS) else v) for k, v in meta.items()} | |
| async def _persist_event(safe: dict[str, Any]) -> None: | |
| """Escribe el evento (ya redactado) en la tabla `events`; best-effort (SF9.3).""" | |
| try: | |
| from backend.db import repositories as repo | |
| from backend.db.session import get_sessionmaker | |
| async with get_sessionmaker()() as session: | |
| await repo.insert_event( | |
| session, | |
| action=safe["action"], | |
| session_id=safe["session_id"], | |
| meta=safe["meta"], | |
| ) | |
| except Exception as error: # noqa: BLE001 — la telemetría jamás debe romper la UI | |
| _logger.warning("No se pudo persistir el evento (best-effort): %s", error) | |
| async def ingest(ev: Event, background: BackgroundTasks) -> dict: | |
| """Valida, redacta y registra un evento (stdout + buffer; persistencia opcional).""" | |
| safe = ev.model_dump() | |
| safe["meta"] = _redact(ev.meta) | |
| _BUFFER.append(safe) | |
| _logger.info("[event] %s %s %s", ev.session_id, ev.action, safe["meta"]) | |
| if get_settings().events_persist: | |
| background.add_task(_persist_event, safe) | |
| return {"ok": True} | |
| def recent(limit: int = 100, session_id: str | None = None) -> list[dict]: | |
| """Devuelve los eventos más recientes (filtrables por `session_id`).""" | |
| items = list(_BUFFER) | |
| if session_id is not None: | |
| items = [e for e in items if e.get("session_id") == session_id] | |
| limit = max(1, min(limit, MAX_EVENTS)) | |
| return items[-limit:] | |
| async def all_events( | |
| limit: int = 200, | |
| session_id: str | None = None, | |
| session: AsyncSession = Depends(get_db), | |
| ) -> list[dict]: | |
| """ | |
| Devuelve eventos persistidos en la tabla `events` (requiere EVENTS_PERSIST=true). | |
| Útil para el visor de telemetría en la UI. | |
| """ | |
| from sqlalchemy import text | |
| query = "SELECT id, action, session_id, meta, created_at FROM events" | |
| params: dict = {} | |
| conditions = [] | |
| if session_id: | |
| conditions.append("session_id = :session_id") | |
| params["session_id"] = session_id | |
| if conditions: | |
| query += " WHERE " + " AND ".join(conditions) | |
| query += " ORDER BY created_at DESC LIMIT :limit" | |
| params["limit"] = min(limit, 1000) | |
| result = await session.execute(text(query), params) | |
| rows = [dict(row._mapping) for row in result.fetchall()] | |
| # Serializar timestamps | |
| for row in rows: | |
| if hasattr(row.get("created_at"), "isoformat"): | |
| row["created_at"] = row["created_at"].isoformat() | |
| return rows | |