Repository navigation
fix: group a NULL analytics group_key as unattributed instead of failing the query (#1347) - #1351
Conversation
…ing the query (#1347) GET /api/v1/accounts/current/analytics/errors?group_by=api_key answered 500 on the console overview page, the first page a user sees after signing in. The card degraded honestly rather than showing a false zero, but the rows it could not scan are exactly the ones an operator most wants to see. usage_events.api_key_id is nullable and is the only nullable grouping column; endpoint and model_alias are both NOT NULL, which is why group_by=model and group_by=endpoint were unaffected. A single NULL in the grouped column failed the scan for the whole result set, so one unattributable row took the entire summary down. Three live sources produce that NULL, and none of them is a data defect. On the demo box today the dominant one is ordinary chat traffic: a request billed to an account through a signed-in session carries no API key at all, and the qa-tester workspace has 14 such rows in the last 24 hours. The second is an error recorded before a key was resolved. The third is a key deleted after the fact, since the foreign key is ON DELETE SET NULL. The scan destination is now nullable and a NULL renders as an explicit unattributed bucket rather than being dropped, because a silent undercount is a worse failure than a visible bucket. The sentinel cannot collide with a real group key, which renders as a UUID. GetUsageSummary and GetSpendSummary carried the identical hole on the same grouping column, and both are fixed here. The new test proves all three failed before and pass after; its fixture contains a genuine NULL group_key and asserts the attributed row is still keyed by its own UUID, so a fix that collapsed every row into the bucket would not pass either.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reachedNext included review available in 23 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughAnalytics summaries now map NULL API-key grouping values to ChangesAnalytics unattributed grouping
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to Analytics summaries now show API-key-less activity as “unattributed” instead of failing the entire result, preserving visibility for affected accounts. No actionable merge-blocking risk remains after normal checks and review. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation The changes satisfy issue Full details: Out of Scope Changes checkExplanation The changes are within scope. The additional usage and spend summary fixes address the same nullable grouping defect, and the client labeling update supports the new unattributed bucket required by the fix. ✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review follow-up on two counts. Antigravity flagged that the live test asserted every value on the unattributed bucket but only the existence of the attributed one, so a regression that corrupted the attributed row's tokens or credits would have passed. Both summaries now assert its values. It also flagged that the fixture guard's comment overstated what the guard buys: the bucket lookups below would already fail if the row stopped being NULL. The guard stays, because it fails pointing at the fixture rather than at the fix, but the comment now says that rather than claiming the assertions are blind. The console change comes from the live capture, which contradicted the reasoning for leaving the front end alone. The Top API keys by spend widget labels any group key missing from the account's key list as "Deleted key", and with the summary now returning the unattributed bucket that row rendered as "Deleted key" with the suffix "unattrib" on the qa-tester workspace. The spend in that bucket is console and chat traffic that carried no API key at all, not a deleted key, so the old label asserts something untrue about the account. It now reads "Unattributed" with the suffix "no key". The sentinel is exported from the console client as UNATTRIBUTED_GROUP_KEY, mirroring usage.UnattributedGroupKey, so the contract value is written once on each side rather than as a bare string in a comparison.
Text half of the visual proof for PR #1351, committed under docs/proof so npm run lint:proof-tokens actually scans it. The screenshots themselves go to the visual-proof-assets release, per the orchestrator contract.
Visual proofConsole overview for the qa-tester workspace, same account and same session in both shots, differing only in which control-plane the console calls. First: the deployed unfixed control-plane, Recent errors renders its failure state. Second: this branch, the card renders 11 real errors. Third: the analytics page grouped by API key on this branch, where the new bucket appears as unattributed on the chart and as Unattributed with the suffix no key in Top API keys by spend, rather than as a deleted key. Full procedure and the before and after API responses are in docs/proof/analytics-null-group-key-1347/capture-log.txt. |
CodeRabbit review follow-up. The bucket collects three causes that the row itself cannot tell apart: traffic that carried no API key, an error recorded before a key was resolved, and a key deleted after the fact under ON DELETE SET NULL. The suffix read "no key", which is a claim about history and is wrong for the third. It now reads "no key on record", which is true under all three: whatever happened earlier, the row carries no key now. The reviewer's other suggestion, an explicit provenance field on the control-plane response, is not implementable from the data. The column is NULL in all three cases and nothing records which one occurred, so a provenance field would have to invent a distinction the database does not hold. Cause-neutral wording is the honest option available. The label was already cause-neutral and is unchanged. The doc comment on fetchTopKeys claimed the only way a spend row has no matching key is a genuinely gone key or a failed fetch, which this branch makes false; it now names the bucket as the third case.
…in it to Go Second review pass, three findings, all of them correct. The constant was exported from lib/control-plane/client.ts, which imports next/headers and the server Supabase client. Type-only imports from that module erase at build time, which is why lib/analytics/cache-metrics.ts promises to take nothing else from it; importing a value broke that promise and pulled a server-only dependency graph into a module that is meant to be pure. Amending that doc comment to permit the import, which is what the previous commit did, was rationalizing the defect rather than fixing it. The constant now lives in lib/control-plane/contract.ts, which imports nothing, and client.ts is back to its original content. The Go constant and the TypeScript one were two decoupled strings with nothing bridging them, so a rename on either side would leave the console labelling every unattributed row as a deleted key again, silently, with every test still green. TestUnattributedGroupKeyMatchesTheConsole reads the console file and pins the two literals to each other. It needs no database and runs in the short leg. Proven red by renaming the console constant to unattributed_traffic. The vitest case keyed its fixture by the same constant the code compares against, so it asserted that a value equals itself and would have survived the constant drifting away from what the control-plane actually sends. The fixture now uses the wire literal, and the constant is asserted against that literal separately. Not changed, deliberately: the analytics table and charts still render group_key verbatim, so the bucket appears there as the lowercase literal next to raw key UUIDs, model aliases and endpoint paths. That column shows identifiers exactly as the API returns them, which is what lets an operator line a row up against a response; prettifying this one value and nothing else around it would be the inconsistency.
The analytics screenshot was re-taken from the branch head after the suffix changed to "no key on record", so the committed log matches the image posted on the PR rather than an earlier build of it.
Visual proofRe-captured from the branch head after review. Top API keys by spend, row 3, now reads Unattributed with the suffix no key on record, replacing the earlier no key: the same bucket also collects rows whose key was genuinely deleted under ON DELETE SET NULL, and nothing in the row says which case it is, so the suffix has to hold for both. This supersedes the third image in the previous proof comment. |
Adversarial review, streams and dispositionsThree streams ran against this branch. Every finding is listed with what happened to it. CodeRabbit CLI (
|
Comments only, no behaviour change. Both sentences had survived several rewrites and read like it.
CodeRabbit follow-up, comments only. The sentence predates this branch and my edit made it worse: extending the list of causes to three left it asserting that a null return distinguishes all of them. It distinguishes exactly one, the failed fetch. The bucket is told apart from a genuinely deleted key by its group key.
Third CodeRabbit pass, on the complete branchRan again after the console rework: 1 finding, Minor, and it is correct.
The sentence predates this branch, but my earlier edit made it worse by extending its list of causes from two to three while leaving the claim intact. Fixed in 221ed5b: the comment now says the null return distinguishes exactly one of the three, the failed fetch, and that the bucket is told apart from a gone key by its group key rather than by this return value. Comments only, no behaviour change. That closes every finding from all three streams. Running totals across the branch:
Nothing is left open. The two rebuttals are argued in the review-streams comment above: the |
## Summary This is the batched buglog follow-up for the pull requests merged to `main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch. `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Scope examined Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of them carried at least one entry, for eighty two entries in total. Thirty two of those were already on `main` and are skipped, leaving fifty appended here from thirty four pull requests. The largest block of skips comes from #1342, the equivalent batch for the 2026-08-28 merges, which merged earlier the same day and already landed thirty six entries covering #1257, #1268, #1276, #1277, #1287, #1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337. ## What landed Fifty entries appended, one JSON object per line, append only. The 232 pre-existing lines are byte identical to `origin/main` (verified by hashing the first 232 lines of the result against the base file). Every line in the resulting file parses as JSON and carries `error_message`, `root_cause`, `fix` and `tags`. | Source | Entries | |---|---| | #1083 | 2 | | #1277 | 1 | | #1278 | 1 | | #1298 | 1 | | #1334 | 1 | | #1336 | 3 | | #1343 | 1 | | #1346 | 1 | | #1351 | 1 | | #1365 | 2 | | #1368 | 1 | | #1369 | 1 | | #1371 | 3 | | #1375 | 3 | | #1376 | 1 | | #1378 | 1 | | #1379 | 2 | | #1388 | 5 | | #1389 | 3 | | #1390 | 2 | | #1393 | 1 | | #1394 | 1 | | #1410 | 1 | | #1417 | 1 | | #1421 | 1 | | #1423 | 1 | | #1424 | 1 | | #1426 | 1 | | #1429 | 1 | | #1431 | 1 | | #1433 | 1 | | #1434 | 1 | | #1436 | 1 | | #1439 | 1 | Entries are copied verbatim from their source pull request bodies. Nothing was rewritten, no field was invented, and no field was added. No JSON needed repair: all eighty two extracted entries parsed on the first attempt and all four required fields were present on every one. ## Merged pull requests that carried no entry Eleven of the fifty nine. Recorded here because the gap is itself the useful signal. | Pull request | Title | Assessment | |---|---|---| | #1013 | chore(deps): bump the go-minor-patch group across 1 directory with 4 updates | Dependabot bump, no defect fixed, no entry expected | | #1015 | chore(deps): bump the go-minor-patch group across 1 directory with 6 updates | Dependabot bump, no entry expected | | #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in /deploy/docker | Dependabot bump, no entry expected | | #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in /apps/desktop | Dependabot bump, no entry expected | | #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in /apps/control-plane | Dependabot bump, no entry expected | | #1342 | chore: batch buglog entries for the 2026-08-28 merges | The previous batch pull request itself, correctly carries no entry of its own | | #1364 | chore: remove four dead skills and record the patterns that cost time | Protocol gap. The body records patterns that cost time, which is the shape of a buglog entry, but none was written as one | | #1383 | test: retire stale expected-failure markers, restore the ones that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails` markers reading as red is a real defect that was fixed here and should have carried an entry | | #1384 | docs: correct D-047, hive-auto reverted to variable pricing (D-059) | Decision ledger correction, arguably a documentation defect, no entry written | | #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in /apps/agent-console | Dependabot bump, no entry expected | | #1398 | docs: rescue the 2026-08-25 parity captures and add the 2026-08-29 QA matrix evidence | Documentation and evidence rescue, no entry written | Six of the eleven are Dependabot bumps and one is the previous batch, so the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those, #1383 is the one worth a follow-up: it fixed a real defect class (a stale expected-failure marker reads as a red "Expect test to fail" and gets dismissed as pre-existing) and left no record. ## Entries skipped as already present Thirty two. Thirty of them matched an entry already on `main` on `error_message`, `id` or `fix`. Two more from #1278 are semantic duplicates that an exact match would have missed, and were skipped after reading the landed entries they duplicate: - #1278's `streaming content_block_start omits text field` entry is covered by the consolidated `bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296, whose root cause names the same `omitempty` on `StreamContentBlock.Text`. - #1278's `GET /v1/models leaked an upstream provider name` entry is covered by `BUG-1284`, landed from #1300, which names the same `public.model_aliases.summary` publication path. #1278's third entry, on `top_k` forwarding producing a 400, is not covered anywhere on `main` and is appended here. #1342 recorded #1278 as fully "merged into #1296", which was accurate for two of its three entries. ## Note on entry quality One appended entry is thin: #1277's parity re-score record carries `error_message` of `n/a` and a root cause of "console had no privacy/data-policy surface at all". It is a parity gap record rather than a defect record. It is included exactly as written rather than embellished, per the protocol's preference for the author's own words. ## Test plan - [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl` and nothing else - [x] First 232 lines byte identical to the base file (md5 match) - [x] All 282 resulting lines parse as JSON and carry `error_message`, `root_cause`, `fix` and `tags` - [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`, `token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit - [ ] The six required checks report green via the inert path allowlist in `.github/workflows/ci.yml` --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>




Closes #1347.
What was broken
GET /api/v1/accounts/current/analytics/errors?group_by=api_key&window=24hanswered 500 on the console overview page, the first page a user sees after signing in. The card degraded honestly rather than showing a false zero, which is the right failure direction, but the rows it could not scan are exactly the ones an operator most wants to see.usage_events.api_key_idis nullable and is the only nullable grouping column:endpointandmodel_aliasare bothNOT NULL(supabase/migrations/20260330_02_usage_accounting.sql), which is whygroup_by=modelandgroup_by=endpointanswered 200 on the same account. One NULL in the grouped column failed the scan for the whole result set, so a single unattributable row took the entire summary down.Where the NULLs actually come from
Three sources, none of them a data defect:
qa-testerworkspace has 14 such rows in the last 24 hours, all of them successfulchat_completionscalls, and that is what was breaking its overview card. This is broader than the issue framing of a pre-auth error.ON DELETE SET NULL.The sibling call, checked
The issue notes
analytics/usage?group_by=modelanswers 200 and infers the errors summary is the only affected path. That inference holds for the grouping column, not for the endpoint.GetUsageSummaryandGetSpendSummarycarry the identical hole on the same nullable column, and the new test shows all three failing with the same error before this change:All three are fixed here rather than only the one the issue names, because all three route through the same shape and the other two would have surfaced the same 500 the first time anyone grouped them by API key. Checked the rest of the package too:
ListEventsandListAttemptsalready scanapi_key_idinto*uuid.UUID, so no other unsafe scan remains.The fix
The scan destination is nullable, and a NULL renders as an explicit
unattributedbucket rather than being dropped. Dropping those rows would trade a loud 500 for a quiet undercount, which is the worse failure. The sentinel cannot collide with a real group key, which renders as a UUID.The console half, which the live capture forced
The plan was to leave the front end alone: the overview card sums
error_countwithout reading the key, and the analytics table rendersgroup_keyverbatim, so the bucket reads correctly there with no change.Capturing the proof disproved that for one widget. Top API keys by spend labels any group key missing from the account key list as "Deleted key", so with the summary now returning the bucket, that row rendered as Deleted key / unattrib on the qa-tester workspace. Nothing was deleted; that spend is chat traffic that never carried a key. It now reads Unattributed / no key on record, wording that is true under all three causes of a NULL, since nothing in the row says which one applies.
The contract constant lives in a new dependency-free
apps/web-console/lib/control-plane/contract.tsrather than inclient.ts, which importsnext/headersand the server Supabase client:cache-metrics.tsis a pure module and a value import fromclient.tswould drag that graph into it.TestUnattributedGroupKeyMatchesTheConsolereads the console file and pins the Go and TypeScript literals to each other, so a rename on either side fails loudly instead of silently restoring the deleted-key label.Tests that can actually fail
TestSummariesByAPIKey_NullKeyGroupsAsUnattributedseeds one attributed row and one genuinely NULL row, then asserts the fixture itself contains exactly one NULLapi_key_idbefore asserting anything else, so a fixture that quietly stopped producing a NULL fails loudly rather than passing. It also asserts the attributed row keeps its own UUID and its own token, credit and count values, so a fix that collapsed every row into the bucket, or corrupted the attributed one, would not pass either.Four mutations were run, each against the real substrate (live Postgres with the full migration chain for the Go ones, a rebuilt image for the console one):
unattributed_traffic: RED on the cross-language guard.expected Deleted key to be Unattributed.The vitest case keys its fixture by the wire literal rather than by the constant the code compares against, so it cannot pass by asserting that a value equals itself.
Verification
go test -tags integration ./internal/usage/... -count=1against live Postgres: pass, whole package.go test ./apps/control-plane/... -count=1 -short: pass.npm run test:unit(web console): 902 pass. The one failing suite,ci-web-e2e-secret-free, fails identically onmaininside the Docker image, which carries no.github/directory; it passes in CI.npm run build(web console): pass.node tools/lint-no-token-in-proof-captures.mjs: pass../internal/usage/...is already in the CI live-Postgres package list, so the live test runs on every PR rather than skipping silently.Live before and after against real demo data, same account and same bearer, one control-plane deployed and unfixed and one built from this branch against a restore of the same database: 500 and 200 respectively, the 200 carrying four attributed key buckets plus
{"group_key":"unattributed","error_count":0,"total_requests":14}. Full procedure indocs/proof/analytics-null-group-key-1347/capture-log.txt; screenshots posted below.Buglog entry
{"id":"bug-2026-08-29-analytics-null-group-key","date":"2026-08-29","title":"Analytics summaries returned 500 when a grouped column was NULL","error_message":"usage: scan error summary row: cannot scan into dest[0] (col: group_key): cannot scan NULL into *string","root_cause":"GetErrorSummary, GetUsageSummary and GetSpendSummary scanned group_key into a non-nullable string. usage_events.api_key_id is nullable (NULL for chat traffic with no API key, for errors recorded before a key is resolved, and for keys deleted under ON DELETE SET NULL), so one unattributable row failed the scan for the entire result set and the console overview card rendered its error state on the first page after sign-in.","fix":"Scan group_key into a nullable destination in all three summaries and map NULL to an explicit unattributed bucket rather than dropping the row. The console Top API keys widget no longer labels that bucket Deleted key. Pinned by TestSummariesByAPIKey_NullKeyGroupsAsUnattributed, TestUnattributedGroupKeyMatchesTheConsole and a console unit case, each proven red by its own mutation.","tags":["control-plane","usage","analytics","postgres","null-scan","console-overview","web-console"],"files":["apps/control-plane/internal/usage/repository.go","apps/control-plane/internal/usage/repository_live_test.go","apps/control-plane/internal/usage/wire_contract_test.go","apps/web-console/lib/analytics/cache-metrics.ts","apps/web-console/lib/control-plane/contract.ts"]}