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:
89
main.py
89
main.py
@@ -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]
|
||||
|
||||
14
models.py
14
models.py
@@ -70,3 +70,17 @@ class Recipe(Base):
|
||||
"rootAuthorName": self.root_author_name,
|
||||
"visibility": self.visibility or "public",
|
||||
}
|
||||
|
||||
|
||||
class TelemetryDaily(Base):
|
||||
"""Contadores de uso ANÓNIMOS y agregados, por día y módulo. NO hay
|
||||
identificador de usuario ni contenido de salud: solo cuántas instalaciones
|
||||
tienen cada módulo activo (`active`) y cuántas veces lo abren (`opens`). La
|
||||
fila especial module='__submissions__' cuenta los envíos del día (≈ nº de
|
||||
instalaciones activas que ceden estadística)."""
|
||||
__tablename__ = "telemetry_daily"
|
||||
|
||||
day = Column(String, primary_key=True) # 'YYYY-MM-DD'
|
||||
module = Column(String, primary_key=True) # nombre del módulo o '__submissions__'
|
||||
active = Column(Integer, default=0)
|
||||
opens = Column(Integer, default=0)
|
||||
|
||||
52
test_telemetry.py
Normal file
52
test_telemetry.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests de telemetría anónima: el POST agrega contadores por día/módulo y el
|
||||
summary los devuelve. SQLite en memoria. Sin identificador de usuario."""
|
||||
import os
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite://"
|
||||
|
||||
from datetime import date # noqa: E402
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from main import app # noqa: E402
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def test_telemetria_agrega_y_resume():
|
||||
day = _today()
|
||||
# Dos envíos de "instalaciones" distintas el mismo día.
|
||||
r1 = client.post("/telemetry", json={
|
||||
"day": day,
|
||||
"app_version": "2.27.0",
|
||||
"active_modules": ["fasting", "weight", "alcohol"],
|
||||
"opens_by_module": {"alcohol": 3, "weight": 1},
|
||||
})
|
||||
assert r1.status_code < 300, r1.text
|
||||
r2 = client.post("/telemetry", json={
|
||||
"day": day,
|
||||
"active_modules": ["fasting", "alcohol"],
|
||||
"opens_by_module": {"alcohol": 2},
|
||||
})
|
||||
assert r2.status_code < 300, r2.text
|
||||
|
||||
summary = client.get("/telemetry/summary").json()
|
||||
assert summary["submissions"] == 2 # dos envíos ese día
|
||||
by_module = {m["module"]: m for m in summary["modules"]}
|
||||
# alcohol: activo en 2 envíos, abierto 3+2 = 5 veces.
|
||||
assert by_module["alcohol"]["activeInstallDays"] == 2
|
||||
assert by_module["alcohol"]["opens"] == 5
|
||||
# fasting: activo en 2 envíos, 0 aperturas.
|
||||
assert by_module["fasting"]["activeInstallDays"] == 2
|
||||
assert by_module["fasting"]["opens"] == 0
|
||||
# No se filtra ningún identificador ni contenido: solo módulos.
|
||||
assert "__submissions__" not in by_module
|
||||
|
||||
|
||||
def test_day_invalido_rechazado():
|
||||
r = client.post("/telemetry", json={"day": "hoy"})
|
||||
assert r.status_code == 400
|
||||
Reference in New Issue
Block a user