Cookiebot
Features

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

DoneM4apiplatform
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/overviewreach, the window's totals and the budget, in one request
GET /admin/analytics/dailyone row per day, summed across every active group
GET /admin/analytics/groupsthe busiest groups in the window
GET /admin/analytics/commandsevery command, with how many groups use each
GET /admin/analytics/llmfleet-wide spend, per provider and model
GET /admin/groupsthe directory of groups the bot knows, keyset-paginated
GET /admin/tenanthow 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 HTTP

The 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.

On this page