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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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.

  • Where it's counted: the count is taken in handle in src/http/server.ts, right after securityFloor, 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.
  • What it records: the route pattern, role and surface. Pattern is the matched route string, so /issues/1, /issues/2 and /issues/3 land on one row. A path that matches no route is counted as (unmatched). Role is anon, user, admin or agent, from the viewer that resolveAuth returns. Surface is api for /api/, mcp for /mcp, and web otherwise.
  • Health checks: /healthz and /readyz are counted under their own patterns, as anon.
  • Hosted children: each child's requests are counted in its own database, and each database has its own counter.
  • Shutdown: Servers.stop() flushes every counter first, before the children's databases close.
  • New file: src/http/usage_tag.ts carries 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: test/usage-count-server.test.ts checks that three issue ids give one row of 3 with agent/api/GET. It also checks that random stranger paths collapse into (unmatched) and that responses keep the security floor.

What I left out:

  • Apps proxied through proxyApp: an app's own traffic is not the forge's answer, so it isn't counted.
  • Reading the data: no page or query reads the table yet.

To try it, boot the app, hit a few routes, wait about a second or stop it, then read usage_count with readUsage.

🤖 Generated with Claude Code

Closes #1918

Changes requested — before the latest push

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 counted call.
  • /healthz and /readyz count under their own patterns.
  • surfaceOf gives mcp for /mcp and web for a page, and roleOf gives anon, user and admin.
  • 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.

commented

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 handle in src/http/server.ts right after securityFloor, for the forge's own answers and for hosted children.
  • Role and surface: role is anon, user, admin or agent, taken from the request's viewer. Surface is api for /api/, mcp for /mcp, and web otherwise.
  • Unmatched paths: a path that matches no route goes to one (unmatched) row.
  • Health routes: /healthz and /readyz are counted under their own patterns, as anon.
  • 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 make count throw, so each such request wrote a log warning. OTHER is now one of the allowed methods in core/usage_count.ts, and methodOf in src/http/usage_tag.ts maps any method outside the list to OTHER before 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 proxyApp is 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

Changes requested — before the latest push

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.

commented

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) in src/http/server.ts builds 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 both answer and a hosted child's http.handle, applies the floor and turns a throw into failure(...). handle then 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's error() hook now calls the same failure as 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 notePeer and 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

Approved

Clean, and it does what #1918 asks.

  • One door: answered + counted in handle cover 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's error() reuses the same failure.
  • Bounded, nothing typed by strangers: pattern is the matched route string or (unmatched); role comes from resolveAuth (a verified session or token, never a typed name); surface is one of three values; methods outside the list become OTHER, so a stranger's PROPFIND no 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: count is 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, OTHER with 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs