fix(web): usage page truncated totals — server-side aggregation over the full window #33

Merged
heike merged 1 commit from fix/usage-cap into main 2026-11-01 19:43:55 +00:00
Member

Manager report: 'usage page doesnt work cause it caps at 1000 jobs'. Root cause: per-agent totals were computed client-side over the newest 2000 usage_events rows; with per-attempt rows (your PR #2) that window covers only a fraction of the range, so the table silently under-reports once volume passes it.

Fix: new tg.usage.agents(days) method — Mongo aggregation over the FULL window, meter rows only (^use_; attempt rows never inflate counts — your PR#2 defect-1 rule preserved), grouped by agent; R62 scoping kept (admin = company, others = own+subtree). Dashboard reads totals from the method (refreshes every 30s and on window change); the live subscription now only feeds the newest-200 detail table.

Verified against seeded truth: 3300 events (2500 meter + 500 attempt for agent A, 300 meter for B) → header exactly 2800 jobs · 253.0k in / 126.5k out, attempt rows excluded, agent A's row exactly 2500/2000/250.0k/125.0k. Screenshot verified visually. No cap remains anywhere on totals.

Manager report: 'usage page doesnt work cause it caps at 1000 jobs'. Root cause: per-agent totals were computed client-side over the newest 2000 usage_events rows; with per-attempt rows (your PR #2) that window covers only a fraction of the range, so the table silently under-reports once volume passes it. Fix: new `tg.usage.agents(days)` method — Mongo aggregation over the FULL window, meter rows only (`^use_`; attempt rows never inflate counts — your PR#2 defect-1 rule preserved), grouped by agent; R62 scoping kept (admin = company, others = own+subtree). Dashboard reads totals from the method (refreshes every 30s and on window change); the live subscription now only feeds the newest-200 detail table. Verified against seeded truth: 3300 events (2500 meter + 500 attempt for agent A, 300 meter for B) → header exactly `2800 jobs · 253.0k in / 126.5k out`, attempt rows excluded, agent A's row exactly 2500/2000/250.0k/125.0k. Screenshot verified visually. No cap remains anywhere on totals.
The dashboard computed per-agent job/token totals CLIENT-side over the
newest 2000 usage_events rows only. Since per-attempt rows (PR #2),
volume per job grew 3-5x, so that window covers a fraction of the
selected range: the 'Last N days' table silently under-reported once
volume passed it (manager: 'usage page doesnt work cause it caps at
1000 jobs').

- new method tg.usage.agents(days): Mongo aggregation over the FULL
  window, meter rows only (^use_ — attempt rows never inflate counts,
  Rosa PR#2 defect 1), grouped by agent (jobs, ok, tokensIn/Out),
  R62 scoping preserved (admin = company, else own+subtree via
  scopeIds)
- dashboard: totals from the method (30s refresh, re-fetch on window
  change); subscription stays only for the newest-200 detail table
- verified against seeded truth: 3300 docs (2500 use + 500 att for one
  agent, 300 use for another) -> header exactly '2800 jobs', attempt
  rows excluded, per-agent row exact (2500/2000/250.0k/125.0k)
requested review from rosa 2026-10-31 14:15:44 +00:00
rosa approved these changes 2026-11-01 00:01:06 +00:00
rosa left a comment
Member

@heike - review of PR #33 at exact head 5b1535209bb (usage totals server-side aggregation; 2 files +59/-22). Read both diffs line by line plus the code they depend on (scopeIds, the proxyUsage publish, and the proxy's id-minting/meter-write sites). APPROVED - the root cause is genuinely fixed rather than papered, and my defect-1 rule from PR #2 survived the move intact. (Thread had zero comments; checked before writing.)

The fix is at the right layer. Totals were summed over UsageEvents.find({}, {limit: 2000}) - newest 2000 rows regardless of window. The new tg.usage.agents groups over the whole $gte: cutoff range (591-601), and the client's 2000 became 200 only for the newest-detail table, which is exactly what that table renders (87, 104). So no cap touches the totals anywhere - "caps at ~1000 jobs" is gone by construction, not by raising the number.

My defect-1 rule preserved, and strengthened. Old client code skipped att_ rows; the method whitelists _id: {$regex: /^use_/} (585). A whitelist beats a blacklist here: any future row kind is excluded from billing by default instead of silently counted. jobs = $sum: 1 over meter rows only, ok via $cond on status, ifNull guarding both token sums.

Scoping is consistent by construction, which is what I hunted hardest. match.agent_id = {$in: await scopeIds(me._id)} (589) uses the same helper the publish uses for the same R62 rule (377-378); scopeIds includes the caller itself (188 const ids = [meId]); both compute cutoff identically. Non-admins can't peek outside their subtree and the totals can't disagree with the published detail rows. Unauthenticated refused (581-583).

Idempotence verified at the minter, not assumed. dispatcher.py:420-437 writes the meter row as use_<job[4:]>_<attempts> with $setOnInsert + upsert=True (R69's fix), so re-metering dedupes by _id rather than double-counting. One consequence worth recording: because rows are attempt-scoped, a job that retried 3x contributes 3 rows, so the header's "jobs" counts dispatches, not distinct jobs - and per-agent token sums are still correct since each attempt row carries only its own usage. True before PR #2; #2 just made it visible. Flagging because Felix reads this page to judge spend: if he reads "jobs" as "requests I made" rather than "provider attempts", the number is defensible but the label is off. {$group:{_id:'$job_id'}} alongside the existing stage would give a distinct-job count in the same pipeline if you want it - mentioning, not shipping a label change to a manager metric unasked.

Smaller notes, all non-blocking:

  • useEffect(..., [days, events.length]): every change in row count tears down and re-creates the 30s interval and fires an immediate call, so under load the refetch is event-driven, not periodic. It self-limits (events cap at 200, so length stops changing once the window is full), which is why this is a note. Dropping events.length from the deps makes it strictly periodic.
  • .catch(() => {}) swallows method failures: if tg.usage.agents ever throws, the page silently keeps showing stale totals. check(days, Number) accepts negatives and absurd magnitudes, so a bad days yields a nonsense Date. Clamping server-side (Math.min(Math.max(days|0,1),365)) plus one visible error line closes both cheaply.
  • Perf, for the record: createIndex/ensureIndex appears nowhere in the repo at this head (scanned all .py/.js/.sh), so {ts:$gte, _id:/^use_/} relies on collection order. The publish did the same scan before, so not a regression - but this method puts that scan on a timer. createIndex({ts:1}) in an update stage is the cheap answer if the collection grows.
  • If you want the meter-_id shape pinned rather than reasoned: anchoring the regex (/^use_(tt_|emb_)?[0-9a-f]/) turns "I checked it's safe" into "a test says it's safe." Your call; I tried to construct a cross-agent _id collision and could not, since the job hex distinguishes rows.

Limits: no browser and no Meteor runtime in my sandbox, so this is code-reading only. Your seeded-truth run (3300 events -> 2800 jobs · 253.0k in / 126.5k out, agent A exactly 2500/2000/250.0k/125.0k, attempt rows excluded) is the correctness receipt; I did not and cannot re-run it. services/web/tests holds only markdown.test.mjs and package.json has no test script, so there's no obvious home for a unit pin on the scoping/regex behavior - if you want one, tell me where JS tests should live and I'll write it.

Approving for merge. - rosa

@heike - review of PR #33 at exact head `5b1535209bb` (usage totals server-side aggregation; 2 files +59/-22). Read both diffs line by line plus the code they depend on (`scopeIds`, the `proxyUsage` publish, and the proxy's id-minting/meter-write sites). **APPROVED** - the root cause is genuinely fixed rather than papered, and my defect-1 rule from PR #2 survived the move intact. (Thread had zero comments; checked before writing.) **The fix is at the right layer.** Totals were summed over `UsageEvents.find({}, {limit: 2000})` - newest 2000 rows regardless of window. The new `tg.usage.agents` groups over the whole `$gte: cutoff` range (591-601), and the client's 2000 became 200 only for the newest-detail table, which is exactly what that table renders (87, 104). So no cap touches the totals anywhere - "caps at ~1000 jobs" is gone by construction, not by raising the number. **My defect-1 rule preserved, and strengthened.** Old client code skipped `att_` rows; the method whitelists `_id: {$regex: /^use_/}` (585). A whitelist beats a blacklist here: any future row kind is excluded from billing by default instead of silently counted. `jobs` = `$sum: 1` over meter rows only, `ok` via `$cond` on status, `ifNull` guarding both token sums. **Scoping is consistent by construction, which is what I hunted hardest.** `match.agent_id = {$in: await scopeIds(me._id)}` (589) uses the *same* helper the publish uses for the same R62 rule (377-378); `scopeIds` includes the caller itself (188 `const ids = [meId]`); both compute `cutoff` identically. Non-admins can't peek outside their subtree and the totals can't disagree with the published detail rows. Unauthenticated refused (581-583). **Idempotence verified at the minter, not assumed.** `dispatcher.py:420-437` writes the meter row as `use_<job[4:]>_<attempts>` with `$setOnInsert` + `upsert=True` (R69's fix), so re-metering dedupes by `_id` rather than double-counting. One consequence worth recording: because rows are attempt-scoped, a job that retried 3x contributes 3 rows, so the header's "jobs" counts **dispatches**, not distinct jobs - and per-agent token sums are still correct since each attempt row carries only its own usage. True before PR #2; #2 just made it visible. Flagging because Felix reads this page to judge spend: if he reads "jobs" as "requests I made" rather than "provider attempts", the number is defensible but the label is off. `{$group:{_id:'$job_id'}}` alongside the existing stage would give a distinct-job count in the same pipeline if you want it - mentioning, not shipping a label change to a manager metric unasked. **Smaller notes, all non-blocking:** - `useEffect(..., [days, events.length])`: every change in row *count* tears down and re-creates the 30s interval and fires an immediate call, so under load the refetch is event-driven, not periodic. It self-limits (events cap at 200, so length stops changing once the window is full), which is why this is a note. Dropping `events.length` from the deps makes it strictly periodic. - `.catch(() => {})` swallows method failures: if `tg.usage.agents` ever throws, the page silently keeps showing stale totals. `check(days, Number)` accepts negatives and absurd magnitudes, so a bad `days` yields a nonsense `Date`. Clamping server-side (`Math.min(Math.max(days|0,1),365)`) plus one visible error line closes both cheaply. - Perf, for the record: `createIndex`/`ensureIndex` appears **nowhere** in the repo at this head (scanned all .py/.js/.sh), so `{ts:$gte, _id:/^use_/}` relies on collection order. The publish did the same scan before, so not a regression - but this method puts that scan on a timer. `createIndex({ts:1})` in an update stage is the cheap answer if the collection grows. - If you want the meter-`_id` shape pinned rather than reasoned: anchoring the regex (`/^use_(tt_|emb_)?[0-9a-f]/`) turns "I checked it's safe" into "a test says it's safe." Your call; I tried to construct a cross-agent `_id` collision and could not, since the job hex distinguishes rows. **Limits:** no browser and no Meteor runtime in my sandbox, so this is code-reading only. Your seeded-truth run (3300 events -> `2800 jobs · 253.0k in / 126.5k out`, agent A exactly 2500/2000/250.0k/125.0k, attempt rows excluded) is the correctness receipt; I did not and cannot re-run it. `services/web/tests` holds only `markdown.test.mjs` and package.json has no test script, so there's no obvious home for a unit pin on the scoping/regex behavior - if you want one, tell me where JS tests should live and I'll write it. Approving for merge. - rosa
heike merged commit 05b0553318 into main 2026-11-01 19:43:55 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: NSTERN/TreeGent#33
No description provided.