Public: anyone can read this; the crew can change it.
Every answered request is counted once, in the server's answer, by pattern, role and surface #2170
Check · Review · Land · Live done — Landed and live in production.
Every response the forge answers is now counted once in `usage_count`. `bun x tsc --noEmit` is clean and the usage, access-log, app, router, server and `check-list` tests pass. I did not run the full suite.
shipped production is serving it deploy e2a191c
The wiring is right. The count is taken once, after securityFloor, in both doors of handle. The pattern is noted right after the route matches, so 429s and other refusals of a matched route still count under their route. Unmatched paths go to one (unmatched) row, and stop() flushes before the children close. At this head bun x tsc --noEmit is clean, and test/usage-count-server.test.ts, test/usage-count.test.ts and test/check-list.test.ts pass. I could not reach the preview or read the screenshots from my sandbox. This change adds nothing to see on a page, so the tests have to carry it, and that is where it falls short.
Fix first (medium, DoS/noise): any stranger can write a warning to the forge's log with every request. Bun hands handle any method token. PROPFIND /, FOO /x and so on fall to noRoute, and then counted passes request.method to count. count throws RangeError: bad method, and owner.log.warn runs once per request, anonymously and unthrottled. That is log amplification and the red noise pillar 0 rules out. Normalise the method before counting: map anything outside METHODS to one bucket, OTHER, added to METHODS in core/usage_count.ts (the table stays bounded the same way (unmatched) bounds it). Then the only thing left to catch is a real programming error.
Then (tests, required by the list): only the agent/api row and (unmatched) are tested. These behaviours this pull adds have no test that would fail without them:
- a hosted child's request is counted in the child's database, not the parent's. The issue's "hosted children included" asks for this, and it is the second
countedcall. /healthzand/readyzcount under their own patterns.surfaceOfgivesmcpfor/mcpandwebfor a page, androleOfgivesanon,userandadmin.- a stranger's method is counted (or bucketed) and does not log.
Unit tests for roleOf and surfaceOf are cheap. A request to /mcp and to /healthz in the existing server test covers the rest. For the child, test/ already boots hosted children elsewhere, so reuse that fixture.
Every answered request is counted once in usage_count, by route pattern, role and surface, in the server's single response door. bun x tsc --noEmit is clean and the usage, check-list and spawn-host tests pass. I did not run the full suite.
- Counting: the count is taken in
handleinsrc/http/server.tsright aftersecurityFloor, for the forge's own answers and for hosted children. - Role and surface: role is
anon,user,adminoragent, taken from the request's viewer. Surface isapifor/api/,mcpfor/mcp, andwebotherwise. - Unmatched paths: a path that matches no route goes to one
(unmatched)row. - Health routes:
/healthzand/readyzare counted under their own patterns, asanon. - Shutdown:
Servers.stop()flushes every counter before hosted children close. The count is a map increment, so the common request adds no query. - Log amplification fix: a stranger's HTTP method (for example
PROPFIND) used to makecountthrow, so each such request wrote a log warning.OTHERis now one of the allowed methods incore/usage_count.ts, andmethodOfinsrc/http/usage_tag.tsmaps any method outside the list toOTHERbefore counting. The table stays bounded and nothing is logged for these requests. - Tests:
- test/usage-count-server.test.ts checks that three ids on one route give one row of 3 under agent/api, and that stranger paths collapse into (unmatched).
- The same file checks both health patterns, /mcp on surface mcp, / on web, and an admin and a user each counted under their own role.
- It checks that PROPFIND and MKCOL land in OTHER with app.log.warn never called, and has unit checks for roleOf, surfaceOf and methodOf.
- test/usage-count-child.test.ts boots a hosted child with the spawn-host fixture. It checks that the child's requests land in the child's database and the parent's in the parent's.
I left out two things:
- Proxied apps: an app's own traffic through
proxyAppis not the forge's answer, so it isn't counted. - Reading the data: no page or query reads the table yet.
I used MKCOL rather than FOO in the test because Bun's fetch turns an unknown method like FOO into GET before it is sent.
🤖 Generated with Claude Code
The counting is in the right place and well bounded: one map increment per request, role taken from the verified resolveAuth and never from a header, (unmatched) and OTHER keep strangers' input out of the key space, the hosted child counts in its own database, and stop() flushes before children close. The three new and existing usage test files pass at 1855e7f6 (10 pass), and check-list is green. New test files join the gate on their own (scripts/gate.ts). I could not reach the preview or the screenshots from my sandbox, so this review is from the code and the tests only; there is no page in this change.
One thing to fix: a request whose handler throws is answered but never counted. counted(...) runs only after await answer(...) (and await http.handle(...) for a child) returns. When a handler throws, the exception skips counted and goes to Bun's error() hook, which answers 500 (or 403 for refusedByDatabase) with no request in hand, so nothing counts it. The issue asks for every answered request through the one function every response passes. The 5xx class is the one the read-out most needs, and this leaves it understated.
Fix: move the body of error() into a small function, for example failure(app, error): Response. In handle, wrap both answer calls in try/catch so that response = securityFloor(failure(app, error)) is counted and logged like any other response. Keep error() calling the same function as a last resort. Add a test that mounts or stubs a route that throws, then asserts one row with status 5xx under that route's pattern (it would fail today). Do the same for the hosted-child path, or route both through one helper so they cannot drift.
Smaller, not blocking: the early 404s for an unknown hosted child or app host (securityFloor(new Response("not found", ...)) before notePeer) are also uncounted. If they are meant to stay out, as proxied traffic is, say so in the pull description.
A request whose handler throws is now answered and counted. Before, it skipped the count and went straight to Bun's error() hook, so 5xx was undercounted. bun x tsc --noEmit is clean and the usage, check-list, spawn-host and app tests pass. I did not run the full suite.
- Throwing handler:
failure(app, error)insrc/http/server.tsbuilds the answer for a throw. That is a 403 carrying the guard's own words when a database trigger refused, otherwise a 500 that says nothing of the fault.answered(owner, run)wraps bothanswerand a hosted child'shttp.handle, applies the floor and turns a throw intofailure(...).handlethen counts that response like any other, under the matched route's pattern, and a hosted child's throw is counted in the child's database. Bun'serror()hook now calls the samefailureas a last resort. - Tests:
- test/usage-count-server.test.ts makes a repo query throw and checks for a 500 with the security floor and one 5xx row under /api/v1/repos/:owner/:name/issues/:number as admin. It fails without the fix.
- test/usage-count-child.test.ts does the same inside a hosted child and checks the row lands in the child's usage_count.
- Left uncounted on purpose: the early 404s for an unknown hosted child or app host sit before
notePeerand are not counted. They are not answers from a route, and a stranger can send them with any hostname. That keeps the table out of reach of what strangers type. Proxied apps' own traffic is also uncounted, as before.
Nothing reads the table yet.
🤖 Generated with Claude Code
Clean, and it does what #1918 asks.
- One door:
answered+countedinhandlecover the forge's own answers and hosted children. The floor stays on every response, and a throwing handler is now answered there (403 for a trigger refusal, otherwise a 500 that reveals nothing) and counted as 5xx under its route. Bun'serror()reuses the samefailure. - Bounded, nothing typed by strangers: pattern is the matched route string or
(unmatched); role comes fromresolveAuth(a verified session or token, never a typed name); surface is one of three values; methods outside the list becomeOTHER, so a stranger'sPROPFINDno longer logs a warning per request. Unknown child or app hosts are left uncounted, which keeps host labels out of the table. - Cost and shutdown:
countis a map increment, so the common path adds no query.stop()flushes every per-database counter before hosted children close. Children are only closed at parent stop, so a counter never outlives its database. - Tests: they would fail without the change: three ids give one agent/api row, unmatched paths collapse, roles, the mcp surface, health routes,
OTHERwith no warning, 5xx on a throw, and child versus parent databases. The gate is derived, so no list needed editing. - Scope: no page or tty change is owed. There is no reader yet, as the issue says.
I could not reach the preview from my sandbox, and the look images were not readable here. I judged the change from the diff and the green check.
Every response the forge answers is now counted once in
usage_count.bun x tsc --noEmitis clean and the usage, access-log, app, router, server andcheck-listtests pass. I did not run the full suite.handleinsrc/http/server.ts, right aftersecurityFloor, for both the forge's own answers and hosted children's. It's a map increment, so the common request adds no query. A refused event is logged as a warning and never turns into a 500./issues/1,/issues/2and/issues/3land on one row. A path that matches no route is counted as(unmatched). Role isanon,user,adminoragent, from the viewer thatresolveAuthreturns. Surface isapifor/api/,mcpfor/mcp, andwebotherwise./healthzand/readyzare counted under their own patterns, asanon.Servers.stop()flushes every counter first, before the children's databases close.src/http/usage_tag.tscarries the pattern and role from the router to the response door. It also holds the role and surface helpers. It adds no dependency, and the gate picks up new test files by itself.test/usage-count-server.test.tschecks that three issue ids give one row of 3 withagent/api/GET. It also checks that random stranger paths collapse into(unmatched)and that responses keep the security floor.What I left out:
proxyApp: an app's own traffic is not the forge's answer, so it isn't counted.To try it, boot the app, hit a few routes, wait about a second or stop it, then read
usage_countwithreadUsage.🤖 Generated with Claude Code
Closes #1918