agrovision / backend /api /events.py
alexp97's picture
feat(fase-11-12): explorador de datos y visor de telemetría en UI
50b8a66
Raw
History Blame Contribute Delete
5.06 kB
"""
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)
@router.post("")
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}
@router.get("/recent")
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:]
@router.get("/all")
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