Fleet-wide analytics and the group directory, owners only
net-new: v1 had no analytics at all, and x_analytics_api deliberately has no fleet-wide endpoint. Seven owner-only reads (overview, daily, top groups, commands, llm, directory, tenant) behind a new admin:read scope that /oauth2/token grants only when the subject is a tenant owner or CB_OWNER_ID. 403 here rather than the group endpoints' 404: /admin has no chat id to hide. The one place in the codebase where a query does not filter on group_id - confined to cb_core/platform_analytics.py, aggregate-only, off the reply path, and argued in that module's docstring
- v1 scenarios
- —
- Ported
- —
- Green
- —
- Failing
- —
net-new: v1 had no analytics at all, and x_analytics_api deliberately has no fleet-wide endpoint. Seven owner-only reads (overview, daily, top groups, commands, llm, directory, tenant) behind a new admin:read scope that /oauth2/token grants only when the subject is a tenant owner or CB_OWNER_ID. 403 here rather than the group endpoints' 404: /admin has no chat id to hide. The one place in the codebase where a query does not filter on group_id - confined to cb_core/platform_analytics.py, aggregate-only, off the reply path, and argued in that module's docstring
What it does
Answers the question x_analytics_api refuses to: how is the bot doing? Not
one group — all of them. How many groups it is in, how many people that reaches,
which groups are alive, which commands anyone actually uses, and what the whole
fleet costs in LLM tokens against the tenant's budget.
Seven reads, all of them aggregate:
GET /admin/overview | reach, the window's totals and the budget, in one request |
GET /admin/analytics/daily | one row per day, summed across every active group |
GET /admin/analytics/groups | the busiest groups in the window |
GET /admin/analytics/commands | every command, with how many groups use each |
GET /admin/analytics/llm | fleet-wide spend, per provider and model |
GET /admin/groups | the directory of groups the bot knows, keyset-paginated |
GET /admin/tenant | how this deployment is configured |
Before this, those numbers existed only in Grafana — which is not something a Mini App can open, and not something the person who runs a community bot from their phone has in front of them.
Behaviour that must not change
Owners, and only owners. A tenant owner (Tenant.owns) or CB_OWNER_ID —
the same people the owner-only Telegram commands answer to. A group admin, however
many groups they run, is not one.
The scope is granted, never assumed. /oauth2/token adds admin:read to a
session only when the subject really is an owner, so a non-owner's token cannot
reach these endpoints however the client edits its own request. GET /me reports
is_bot_admin so a Mini App knows whether to draw the screens at all, rather
than provoking a 403 to find out.
403 here, not 404. The exact opposite of the group endpoints, on purpose:
those hide behind a 404 because a chat id is worth hiding, and /admin/… is a
fixed path published in /openapi.json. A 404 there would tell an authorised
owner with a stale token the same thing it tells an outsider.
Reads only, and no member data. Nothing here returns a message, a member's
name or a group's rules. An owner who wants a group's settings calls
/groups/{id}/config like anybody else — being a tenant owner is what lets them
in. "Run the fleet" and "read a member's data" are different powers, and only one
of them is in this router.
The one place a query does not filter on group_id
AGENTS.md §4.1 says every query filters on the distribution column. These do not,
and the departure is argued in full in cb_core/platform_analytics.py: the
queries read the daily rollups rather than events, every one of them aggregates
before returning so only the grouped result crosses the network, and nothing on
the reply path calls them. The fan-out lives in one named module so it can be
found again and never copied into a handler. If a deployment outgrows it, the fix
is a platform_daily_stats rollup in cb-worker — the shapes here are already
what one would return.
How to verify it
uv run scripts/qa_setup.py # a running API, seeded data, three tokens
python scripts/cb.py api-test # smoke, contract and integration over HTTPThe rows that matter most are the refusals: a group admin asking
/admin/overview and getting 403, and a stranger getting the same. Unit
coverage is packages/cb-api/tests/test_admin_endpoints.py; every response is
validated against the published openapi.json in qa/api/test_contract.py; the
SQL is exercised against real Citus in qa/integration/test_platform_analytics.py,
including an EXPLAIN asserting the fan-out aggregates rather than shipping
rows.
Mongo -> Citus backfill
all 8 collections. 7 through the pure ETL mappers, idempotent upserts; randomdatabase through cb.py backfill-random, which downloads what each v1 pointer references and writes a real media_objects row - resumable by telegram_file_id, so a second run costs a query per pointer, not a download
Per-group analytics endpoints
four reads over the rollups: daily, commands, llm, summary. Bearer token from /login plus group_admins membership, 404 (not 403) for a group you do not administer, bounded windows, no fleet-wide endpoint - every query is single-shard