Count what is actually asking, before choosing what to charge it - #166
Merged
Conversation
A million requests a month and fourteen accounts is not one fact, it is three populations summed together: people reading, agents calling the API and the MCP endpoint on purpose, and scrapers walking every HTML page to rebuild what /api/feeds hands over in one request. Those want opposite things done about them and none of them is visible in a total. The tiering this is for -- a free anonymous allowance, a larger one for signed-in readers, and sponsorship above that -- is a set of numbers somebody has to choose, and choosing them blind fails in both directions. Set the ceiling under what a reader does in an hour and the directory breaks for people; set it over what a scraper does and it costs exactly what having no limit costs. So: measure first, price second. traffic_hourly is shaped like crawl_hourly and queue_hourly, one row per hour per (agent, bucket, tier), folded in with an accumulating upsert. Per-request rows were never an option -- a write transaction on this database can take half a minute under crawl load, and that does not belong in a page render. The counter buffers in memory and flushes once a minute, so an hour of traffic is sixty batches rather than tens of thousands of transactions competing with the crawler for one lock. Three things worth knowing about the shape of it: - The classifier splits AI crawlers by vendor and keeps SEO scrapers apart from search indexing, because "should this pay" is asked one crawler at a time. Googlebot sends readers; Google-Extended trains on us. A taxonomy that cannot separate them forces one answer for both. - Browser detection runs last. Nearly every crawler also says Mozilla and several name a browser engine deliberately, which is how a million requests comes to look like an audience. - Counting happens before the throttle decides, so a refused request is still counted. A limit that hides the traffic it turns away cannot be tuned against anything. It records a user-agent family and a route kind. Never a raw UA, never an address, never who read what. Folded into the existing proxy rather than added beside it: Next 16 allows one proxy file per app, and that file already runs in front of every request for the crawl throttle. The counter is wrapped and swallowed throughout -- it runs ahead of every response on the site, and an outage caused by bookkeeping is the least defensible kind there is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SisjDW4F1zwTeySnUM6VbB
ralyodio
marked this pull request as ready for review
September 2, 2026 13:26
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
We have ~1M requests a month and 14 accounts, 1 of them active besides Anthony. That gap is the thing to act on, but "1M pageviews" is not one fact — it is three populations summed together:
/mcpon purpose, which is the product working and the thing a sponsorship would charge for;/api/feedshands over in one request — pure cost, no relationship.The tiering we want (free anonymous allowance, ~10x for signed-in readers, sponsorship above that) is a set of numbers somebody has to pick, and picking them blind fails both ways: under what a reader does in an hour and the directory breaks for people; over what a scraper does and it costs exactly what no limit costs.
So this PR measures. It changes no limit and refuses no request.
What is here
traffic_hourly— one row per hour per(agent, bucket, tier), accumulating upsert. Shaped like the existingcrawl_hourly/queue_hourlyrollups.apps/web/src/lib/traffic.js— the classifier and an in-memory counter. Pure: no Next, no request object, no database, which is what makes it testable.apps/web/src/lib/trafficCounter.js— the wiring, called first thing inproxy.js.GET /api/traffic?hours=—byAgent,byBucket,byTier,byHour, andreaderByAgentbroken out on its own.Design notes
Buffered, not per-request. A write transaction on this database can take 29–300s under crawl load. A row per request would be tens of thousands of transactions an hour competing with the poller for one lock. The counter flushes once a minute — an hour of traffic is 60 batches, and the worst case is losing a minute of counters, which for choosing a rate limit is nothing.
Counting runs before the throttle decides, so a refused request is still counted. A limit that hides the traffic it turns away cannot be tuned against anything.
Browser detection runs last. Nearly every crawler also says
Mozilla/5.0and several name Chrome or Safari deliberately. There is a test asserting six real crawler UAs do not classify asbrowser— get that order wrong and a million scraper requests read as an audience, which is roughly the mistake this PR exists to stop us making twice.AI crawlers split by vendor; SEO scrapers kept apart from search. Googlebot sends readers, Google-Extended trains on us, AhrefsBot does neither. "Should this pay" is a question asked one crawler at a time, and a taxonomy that cannot separate them forces one answer for all of them.
Folded into the existing
proxy.jsrather than added beside it — Next 16 allows one proxy file per app, and that file already runs in front of every request for the crawl throttle. Every path through the counter is wrapped and swallowed: it runs ahead of every response on the site, and an outage caused by bookkeeping is the least defensible kind there is.Privacy
Records a user-agent family and a route kind. Never a raw UA string, never an address, never who read what.
/api/trafficis open and metered by the sameguard()as the rest of the API — there is nothing in it that identifies a caller.What this does not do
FREE_PER_WINDOWis still 120/min, signed-in is still unmetered, API keys still 5000/hr.anon— the key is read inapiguard, after the proxy has run. Soanonis an upper bound, and closing that gap is the first job of the tiering work.Testing
apps/web/test/traffic.test.js, built on real UA strings as sent.apps/web).pnpm buildpasses with the proxy registered and/api/trafficin the manifest.After merge
The poller applies the migration on boot. Give it a few days, then
GET /api/traffic?hours=168is the input to the tier numbers.🤖 Generated with Claude Code
https://claude.ai/code/session_01SisjDW4F1zwTeySnUM6VbB