telemetría: tiempo por pantalla (columna seconds) + Aprende/subsecciones
telemetry_daily gana columna `seconds` (migración idempotente ALTER ADD COLUMN
IF NOT EXISTS para el Postgres ya desplegado; create_all para SQLite fresco). El
POST acepta seconds_by_module y lo agrega por clave (tope 86400/clave/envío); el
summary suma y devuelve seconds por módulo/vista. Las claves ya eran genéricas,
así que Aprende ('aprende', 'aprende:<tema>') y las vistas ('today', '__other__')
entran sin cambios de esquema. Tests actualizados.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
45
main.py
45
main.py
@@ -59,6 +59,20 @@ def _migrate_recipes():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_telemetry():
|
||||||
|
"""Añade en Postgres la columna `seconds` de `telemetry_daily` si falta
|
||||||
|
(la tabla ya existe desde v2.27 sin esa columna; create_all no la altera)."""
|
||||||
|
if not engine.url.get_backend_name().startswith("postgres"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE telemetry_daily ADD COLUMN IF NOT EXISTS "
|
||||||
|
"seconds INTEGER DEFAULT 0"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def init_db(retries: int = 10):
|
def init_db(retries: int = 10):
|
||||||
"""Crea las tablas, reintentando por si la BD tarda en levantar. No tumba
|
"""Crea las tablas, reintentando por si la BD tarda en levantar. No tumba
|
||||||
el arranque si falla: /health seguirá respondiendo."""
|
el arranque si falla: /health seguirá respondiendo."""
|
||||||
@@ -67,6 +81,7 @@ def init_db(retries: int = 10):
|
|||||||
try:
|
try:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_recipes()
|
_migrate_recipes()
|
||||||
|
_migrate_telemetry()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -265,28 +280,31 @@ class TelemetryIn(BaseModel):
|
|||||||
app_version: str | None = None
|
app_version: str | None = None
|
||||||
active_modules: list[str] = []
|
active_modules: list[str] = []
|
||||||
opens_by_module: dict[str, int] = {}
|
opens_by_module: dict[str, int] = {}
|
||||||
|
seconds_by_module: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/telemetry")
|
@app.post("/telemetry")
|
||||||
def add_telemetry(body: TelemetryIn, db: Session = Depends(get_db)):
|
def add_telemetry(body: TelemetryIn, db: Session = Depends(get_db)):
|
||||||
"""Recibe un lote diario y agregado de uso. NO lleva identificador: solo
|
"""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."""
|
incrementa contadores por día y módulo/vista. No guarda IP, nombre ni
|
||||||
|
contenido."""
|
||||||
day = (body.day or "").strip()[:10]
|
day = (body.day or "").strip()[:10]
|
||||||
if len(day) != 10:
|
if len(day) != 10:
|
||||||
raise HTTPException(status_code=400, detail="day inválido (YYYY-MM-DD)")
|
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:
|
# Agregamos los incrementos de ESTE envío por clave antes de tocar la BD:
|
||||||
# un módulo puede aparecer en active_modules y en opens_by_module, y crear
|
# una clave puede aparecer en varios mapas, y crear dos filas pendientes con
|
||||||
# dos filas pendientes con la misma PK reventaría el INSERT.
|
# la misma PK reventaría el INSERT.
|
||||||
agg: dict[str, dict[str, int]] = {}
|
agg: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
def add(module: str, active: int = 0, opens: int = 0):
|
def add(module: str, active: int = 0, opens: int = 0, seconds: int = 0):
|
||||||
module = str(module).strip()[:40]
|
module = str(module).strip()[:40]
|
||||||
if not module:
|
if not module:
|
||||||
return
|
return
|
||||||
e = agg.setdefault(module, {"active": 0, "opens": 0})
|
e = agg.setdefault(module, {"active": 0, "opens": 0, "seconds": 0})
|
||||||
e["active"] += active
|
e["active"] += active
|
||||||
e["opens"] += opens
|
e["opens"] += opens
|
||||||
|
e["seconds"] += seconds
|
||||||
|
|
||||||
# Un envío = una instalación activa ese día que cede estadística.
|
# Un envío = una instalación activa ese día que cede estadística.
|
||||||
add("__submissions__", active=1)
|
add("__submissions__", active=1)
|
||||||
@@ -299,14 +317,23 @@ def add_telemetry(body: TelemetryIn, db: Session = Depends(get_db)):
|
|||||||
continue
|
continue
|
||||||
if c > 0:
|
if c > 0:
|
||||||
add(m, opens=min(c, 100000))
|
add(m, opens=min(c, 100000))
|
||||||
|
for m, s in (body.seconds_by_module or {}).items():
|
||||||
|
try:
|
||||||
|
s = int(s)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if s > 0:
|
||||||
|
# Tope por clave y envío (~1 día): evita valores absurdos.
|
||||||
|
add(m, seconds=min(s, 86400))
|
||||||
|
|
||||||
for module, delta in agg.items():
|
for module, delta in agg.items():
|
||||||
row = db.get(TelemetryDaily, (day, module))
|
row = db.get(TelemetryDaily, (day, module))
|
||||||
if row is None:
|
if row is None:
|
||||||
row = TelemetryDaily(day=day, module=module, active=0, opens=0)
|
row = TelemetryDaily(day=day, module=module, active=0, opens=0, seconds=0)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
row.active = (row.active or 0) + delta["active"]
|
row.active = (row.active or 0) + delta["active"]
|
||||||
row.opens = (row.opens or 0) + delta["opens"]
|
row.opens = (row.opens or 0) + delta["opens"]
|
||||||
|
row.seconds = (row.seconds or 0) + delta["seconds"]
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -323,6 +350,7 @@ def telemetry_summary(days: int = 30, db: Session = Depends(get_db)):
|
|||||||
TelemetryDaily.module,
|
TelemetryDaily.module,
|
||||||
func.sum(TelemetryDaily.active),
|
func.sum(TelemetryDaily.active),
|
||||||
func.sum(TelemetryDaily.opens),
|
func.sum(TelemetryDaily.opens),
|
||||||
|
func.sum(TelemetryDaily.seconds),
|
||||||
)
|
)
|
||||||
.filter(TelemetryDaily.day >= cutoff)
|
.filter(TelemetryDaily.day >= cutoff)
|
||||||
.group_by(TelemetryDaily.module)
|
.group_by(TelemetryDaily.module)
|
||||||
@@ -330,7 +358,7 @@ def telemetry_summary(days: int = 30, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
submissions = 0
|
submissions = 0
|
||||||
modules = []
|
modules = []
|
||||||
for module, active, opens in rows:
|
for module, active, opens, seconds in rows:
|
||||||
if module == "__submissions__":
|
if module == "__submissions__":
|
||||||
submissions = int(active or 0)
|
submissions = int(active or 0)
|
||||||
continue
|
continue
|
||||||
@@ -338,6 +366,7 @@ def telemetry_summary(days: int = 30, db: Session = Depends(get_db)):
|
|||||||
"module": module,
|
"module": module,
|
||||||
"activeInstallDays": int(active or 0),
|
"activeInstallDays": int(active or 0),
|
||||||
"opens": int(opens or 0),
|
"opens": int(opens or 0),
|
||||||
|
"seconds": int(seconds or 0),
|
||||||
})
|
})
|
||||||
modules.sort(key=lambda x: x["opens"], reverse=True)
|
modules.sort(key=lambda x: x["opens"], reverse=True)
|
||||||
return {"sinceDays": days, "submissions": submissions, "modules": modules}
|
return {"sinceDays": days, "submissions": submissions, "modules": modules}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class TelemetryDaily(Base):
|
|||||||
__tablename__ = "telemetry_daily"
|
__tablename__ = "telemetry_daily"
|
||||||
|
|
||||||
day = Column(String, primary_key=True) # 'YYYY-MM-DD'
|
day = Column(String, primary_key=True) # 'YYYY-MM-DD'
|
||||||
module = Column(String, primary_key=True) # nombre del módulo o '__submissions__'
|
module = Column(String, primary_key=True) # nombre del módulo/vista o '__submissions__'
|
||||||
active = Column(Integer, default=0)
|
active = Column(Integer, default=0)
|
||||||
opens = Column(Integer, default=0)
|
opens = Column(Integer, default=0)
|
||||||
|
seconds = Column(Integer, default=0) # tiempo en pantalla acumulado
|
||||||
|
|||||||
@@ -25,21 +25,27 @@ def test_telemetria_agrega_y_resume():
|
|||||||
"app_version": "2.27.0",
|
"app_version": "2.27.0",
|
||||||
"active_modules": ["fasting", "weight", "alcohol"],
|
"active_modules": ["fasting", "weight", "alcohol"],
|
||||||
"opens_by_module": {"alcohol": 3, "weight": 1},
|
"opens_by_module": {"alcohol": 3, "weight": 1},
|
||||||
|
"seconds_by_module": {"alcohol": 120, "aprende:sueno": 45},
|
||||||
})
|
})
|
||||||
assert r1.status_code < 300, r1.text
|
assert r1.status_code < 300, r1.text
|
||||||
r2 = client.post("/telemetry", json={
|
r2 = client.post("/telemetry", json={
|
||||||
"day": day,
|
"day": day,
|
||||||
"active_modules": ["fasting", "alcohol"],
|
"active_modules": ["fasting", "alcohol"],
|
||||||
"opens_by_module": {"alcohol": 2},
|
"opens_by_module": {"alcohol": 2},
|
||||||
|
"seconds_by_module": {"alcohol": 60},
|
||||||
})
|
})
|
||||||
assert r2.status_code < 300, r2.text
|
assert r2.status_code < 300, r2.text
|
||||||
|
|
||||||
summary = client.get("/telemetry/summary").json()
|
summary = client.get("/telemetry/summary").json()
|
||||||
assert summary["submissions"] == 2 # dos envíos ese día
|
assert summary["submissions"] == 2 # dos envíos ese día
|
||||||
by_module = {m["module"]: m for m in summary["modules"]}
|
by_module = {m["module"]: m for m in summary["modules"]}
|
||||||
# alcohol: activo en 2 envíos, abierto 3+2 = 5 veces.
|
# alcohol: activo en 2 envíos, abierto 3+2 = 5 veces, 120+60 = 180 s.
|
||||||
assert by_module["alcohol"]["activeInstallDays"] == 2
|
assert by_module["alcohol"]["activeInstallDays"] == 2
|
||||||
assert by_module["alcohol"]["opens"] == 5
|
assert by_module["alcohol"]["opens"] == 5
|
||||||
|
assert by_module["alcohol"]["seconds"] == 180
|
||||||
|
# Una subsección de Aprende con tiempo, sin ser módulo activo.
|
||||||
|
assert by_module["aprende:sueno"]["seconds"] == 45
|
||||||
|
assert by_module["aprende:sueno"]["opens"] == 0
|
||||||
# fasting: activo en 2 envíos, 0 aperturas.
|
# fasting: activo en 2 envíos, 0 aperturas.
|
||||||
assert by_module["fasting"]["activeInstallDays"] == 2
|
assert by_module["fasting"]["activeInstallDays"] == 2
|
||||||
assert by_module["fasting"]["opens"] == 0
|
assert by_module["fasting"]["opens"] == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user