metrics: daily snapshot of members and usage, backfillable, plus a read route
The co-op had no longitudinal data: every number was an ad-hoc query, so
nothing compounded and no single source owned "how many members do we have,
and when did that change". That cost us a wrong figure in the newsletter ("six
new members" — actually 21; the six were forum joins).
New: metrics_snapshot.py writes one row per day into the portal's SQLite store
(the same members.db), and GET /admin/metrics/series returns them oldest-first
for the panel and dashboard to chart later.
- Cumulative columns (members_active, members_total, members_new,
newsletter_subs, matrix_space_members) are counts AS OF that day;
spend_usd/requests are THAT day's traffic.
- FAIL CLOSED PER METRIC: an unreadable source yields NULL, never a zero — a
zero reads as "nobody" and would bake a lie into a chart. Failures print to
stdout, and a scheduler task's stdout lands in the app log.
- Listmonk's cached subscriber_count is NOT trusted (it reads 142 for a list
holding 71): the count comes from paging the subscribers endpoint.
- Member growth comes from welcome_sent_at, the provisioning timestamp —
the closest thing the portal stores to a join date. Manual/founder members
have no welcome email, so backfilled days are a documented LOWER BOUND;
today's row carries the exact count.
- Runs as an in-app scheduler task (addons.scheduler, 03:15 UTC) rather than
an agent cron: member-facing plumbing should not depend on the agent VM.
- Backfill is idempotent; member growth is reconstructible back to
September and spend/requests back to the gateway's first request.
Charts land later, on the surfaces, once the shape of the series is reviewed.
This commit is contained in:
1 parent
403a773f53
commit
70ae435867
4 files changed
+350
-1
No files matched your search
+60
@@ -607,6 +607,24 @@ def get_db() -> sqlite3.Connection:
|
||||
"active INTEGER DEFAULT 1"
|
||||
")"
|
||||
)
|
||||
# Daily metrics snapshot, written by /app/code/metrics_snapshot.py from the
|
||||
# scheduler and read by the admin panel + dashboard. Cumulative columns
|
||||
# (members_*, newsletter_subs, matrix_space_members) are counts AS OF that
|
||||
# day; spend_usd/requests are THAT day's traffic. A NULL column means the
|
||||
# source could not be read that day — charts must show a gap, never a zero.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS metrics_daily ("
|
||||
"day TEXT PRIMARY KEY, "
|
||||
"members_active INTEGER, "
|
||||
"members_total INTEGER, "
|
||||
"members_new INTEGER, "
|
||||
"newsletter_subs INTEGER, "
|
||||
"matrix_space_members INTEGER, "
|
||||
"spend_usd REAL, "
|
||||
"requests INTEGER, "
|
||||
"recorded_at TEXT"
|
||||
")"
|
||||
)
|
||||
# Member-managed API keys (created via the broker). Each row is one API key
|
||||
# under the member's team, tracked so we can list/revoke by name without
|
||||
# exposing the chat key. `sk_token` is the full sk- key (revealed once).
|
||||
@@ -3036,6 +3054,48 @@ async def admin_overview(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/admin/metrics/series")
|
||||
@limiter.limit("30/minute")
|
||||
async def admin_metrics_series(request: Request, days: int = 90):
|
||||
"""Daily metrics series for charts.
|
||||
|
||||
Authenticated by the X-Admin-Token header. Returns oldest-first rows from
|
||||
`metrics_daily` for the last `days` days, for the admin panel and the member
|
||||
dashboard to chart from one source of truth. A NULL column means that source
|
||||
could not be read on that day: render a gap, not a zero.
|
||||
|
||||
Cumulative columns (members_*, newsletter_subs, matrix_space_members) are
|
||||
counts as of that day; spend_usd/requests are that day's traffic only.
|
||||
Backfilled days carry a LOWER BOUND for members_total (members provisioned
|
||||
without a welcome email are absent from the history).
|
||||
"""
|
||||
_verify_admin_token(request)
|
||||
limit = max(1, min(int(days or 90), 730))
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT day, members_active, members_total, members_new, newsletter_subs, "
|
||||
"matrix_space_members, spend_usd, requests, recorded_at "
|
||||
"FROM metrics_daily ORDER BY day DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
cols = [
|
||||
"day",
|
||||
"members_active",
|
||||
"members_total",
|
||||
"members_new",
|
||||
"newsletter_subs",
|
||||
"matrix_space_members",
|
||||
"spend_usd",
|
||||
"requests",
|
||||
"recorded_at",
|
||||
]
|
||||
series = [dict(zip(cols, row)) for row in reversed(rows)]
|
||||
return {"series": series, "count": len(series), "days": limit}
|
||||
|
||||
|
||||
@app.get("/admin/model-usage")
|
||||
@limiter.limit("30/minute")
|
||||
async def admin_model_usage(request: Request, days: int = 0):
|
||||
|
||||
Reference in new issue
Block a user