telemetría anónima: POST /telemetry (contadores agregados) + GET /telemetry/summary

Tabla telemetry_daily (day, module, active, opens) sin identificador de usuario
ni contenido: solo cuenta cuántas instalaciones tienen cada módulo activo y
cuántas veces lo abren (fila __submissions__ = envíos del día). El POST agrega
por módulo antes de tocar la BD (evita PK duplicada intra-request). create_all
crea la tabla nueva; no necesita migración ALTER. Tests en SQLite en memoria.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-07-03 21:27:39 +02:00
parent 9157d12f67
commit 3092e19b73
3 changed files with 154 additions and 1 deletions

89
main.py
View File

@@ -21,7 +21,7 @@ def clamp_limit(limit: int) -> int:
import off
from db import Base, engine, get_db
from models import Food, Recipe
from models import Food, Recipe, TelemetryDaily
# Columnas añadidas a `recipes` después de la v1 (id/name/servings/items). En
@@ -256,6 +256,93 @@ def delete_cookbook(author_id: str, db: Session = Depends(get_db)):
return {"deleted": n}
# ---------------------------------------------------------------------------
# Telemetría de uso ANÓNIMA (opt-in en la app; apagada por defecto)
# ---------------------------------------------------------------------------
class TelemetryIn(BaseModel):
day: str
app_version: str | None = None
active_modules: list[str] = []
opens_by_module: dict[str, int] = {}
@app.post("/telemetry")
def add_telemetry(body: TelemetryIn, db: Session = Depends(get_db)):
"""Recibe un lote diario y agregado de uso. NO lleva identificador: solo
incrementa contadores por día y módulo. No guarda IP, nombre ni contenido."""
day = (body.day or "").strip()[:10]
if len(day) != 10:
raise HTTPException(status_code=400, detail="day inválido (YYYY-MM-DD)")
# Agregamos los incrementos de ESTE envío por módulo antes de tocar la BD:
# un módulo puede aparecer en active_modules y en opens_by_module, y crear
# dos filas pendientes con la misma PK reventaría el INSERT.
agg: dict[str, dict[str, int]] = {}
def add(module: str, active: int = 0, opens: int = 0):
module = str(module).strip()[:40]
if not module:
return
e = agg.setdefault(module, {"active": 0, "opens": 0})
e["active"] += active
e["opens"] += opens
# Un envío = una instalación activa ese día que cede estadística.
add("__submissions__", active=1)
for m in (body.active_modules or [])[:40]:
add(m, active=1)
for m, c in (body.opens_by_module or {}).items():
try:
c = int(c)
except (TypeError, ValueError):
continue
if c > 0:
add(m, opens=min(c, 100000))
for module, delta in agg.items():
row = db.get(TelemetryDaily, (day, module))
if row is None:
row = TelemetryDaily(day=day, module=module, active=0, opens=0)
db.add(row)
row.active = (row.active or 0) + delta["active"]
row.opens = (row.opens or 0) + delta["opens"]
db.commit()
return {"ok": True}
@app.get("/telemetry/summary")
def telemetry_summary(days: int = 30, db: Session = Depends(get_db)):
"""Resumen agregado de los últimos [days] días: envíos y, por módulo, en
cuántos días-instalación estuvo activo y cuántas veces se abrió."""
from datetime import date, timedelta
days = max(1, min(days, 3650))
cutoff = (date.today() - timedelta(days=days)).isoformat()
rows = (
db.query(
TelemetryDaily.module,
func.sum(TelemetryDaily.active),
func.sum(TelemetryDaily.opens),
)
.filter(TelemetryDaily.day >= cutoff)
.group_by(TelemetryDaily.module)
.all()
)
submissions = 0
modules = []
for module, active, opens in rows:
if module == "__submissions__":
submissions = int(active or 0)
continue
modules.append({
"module": module,
"activeInstallDays": int(active or 0),
"opens": int(opens or 0),
})
modules.sort(key=lambda x: x["opens"], reverse=True)
return {"sinceDays": days, "submissions": submissions, "modules": modules}
@app.post("/recipes")
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
items = [i.model_dump() for i in body.items]