From a327811f36d064ddf676c78672289aec622425cb Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:52:26 +0200 Subject: [PATCH 001/187] the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x (#2732) * the plan-band doc said the opposite of the code, and GetBlock's archive step is 2x, not 1.5x Two comments in the rpc-cost harness teach the next reader the belief the code was changed to remove. profiles.go carried two doc paragraphs on PlanTiers. The upper one said `all` is "the cheapest plan of any tier" and that "Free is scored here too"; the lower one, added with the paid-plan default, says `all` is the cheapest PAID plan because free tiers would hand the leaderboard to the $0 rows. The code is the lower one: model.go sets `paidOnly := tier == "all" && !p.isReference()` and skips TierFree, and benchmarks/rpc-cost.yml labels the tab "Cheapest paid plan". A reader scanning top-down got the stale answer, which is the exact defect the paid-plan default exists to prevent. The stale paragraph goes. model.go and catalogue.go both gave GetBlock's archive ratio as 1.5x on one method family. Read off the full_cu / archive_cu columns in pricing/catalogue.yml, the Ethereum catch-all reads are 20 -> 40 (2x) and the enumerated debug, trace and txpool methods are 40 -> 120 (3x); Solana is 50 -> 100 throughout. No ratio in the table is 1.5x, and the spec already states 2x and 3x. model.go was also naming the same 3x method set twice ("debug/trace" and "the enumerated trace family") and never mentioning the plain reads that are the real second case. The 1.5x is a real number in the wrong place: it is what GetBlock's published archive_multiplier ceiling of 6 implies against the 4x method family, which is why that formula is unusable. That is recorded on the getblock archive_rule note, and model.go now points there instead of repeating the figure as if it described these columns. Comments only. No behaviour change, no figure on the page moves. * the 90-day staleness rule these two comments describe does not exist catalogue.go said "A number nobody has re-verified in 90 days is reported as such on the page instead of quietly passing for current", and refresh.go said "a figure nobody has re-read in 90 days is the thing this bench most needs to admit". There is no 90-day constant in the harness. grep over the package finds the number only in these two comments and in a NOWNodes plan note about a package valid 90 days; benchmarks/rpc-cost.yml carries no threshold either. What the harness does is publish rpc_pricing_artifact_age_seconds per provider per artifact (metrics.go:120) and compare it to nothing. The distinction matters to anyone reading this before touching the freshness path: there is no cutoff past which a figure is withheld or relabelled, so adding one is a feature, not a bug fix, and removing the gauge would remove the only staleness signal there is. The spec already words it correctly at line 87, "Age is published either way, so a figure nobody has re-read is visible as such", so both comments now say that and name the gauge. Comments only. go build and go vet clean. --- harnesses/rpc-cost/cmd/script/catalogue.go | 13 +++++++++---- harnesses/rpc-cost/cmd/script/model.go | 9 +++++++-- harnesses/rpc-cost/cmd/script/profiles.go | 12 ++++-------- harnesses/rpc-cost/cmd/script/refresh.go | 6 ++++-- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/harnesses/rpc-cost/cmd/script/catalogue.go b/harnesses/rpc-cost/cmd/script/catalogue.go index 0b72d1ec6..2bd575450 100644 --- a/harnesses/rpc-cost/cmd/script/catalogue.go +++ b/harnesses/rpc-cost/cmd/script/catalogue.go @@ -16,8 +16,12 @@ import ( // artifact it came from, the harness re-fetches those artifacts on a // loop, and the bench publishes how stale each provider's numbers are. // -// A number nobody has re-verified in 90 days is reported as such on the -// page instead of quietly passing for current. +// Note what that check is and is not: the age is PUBLISHED, per provider +// per artifact, as rpc_pricing_artifact_age_seconds. Nothing in this +// harness compares it to a threshold, so there is no cutoff past which a +// figure is withheld or relabelled. A stale number is visible as stale +// because its age is on the board next to it, which is what the spec +// claims ("Age is published either way"), and nothing more. type Catalogue struct { Version int `yaml:"version"` @@ -54,8 +58,9 @@ type Provider struct { // ArchiveWeights is a full second table for providers whose archive // cost is an independent lookup rather than a multiple of the // standard cost. GetBlock is the case that forced it: its API returns - // full_cu and archive_cu per method, and the ratio is 3x on one family - // of methods and 1.5x on another, so no single multiplier is correct. + // full_cu and archive_cu per method, and the ratio is 2x on the + // catch-all reads and 3x on the enumerated debug, trace and txpool + // methods, so no single multiplier is correct. // When present it replaces Weights entirely for archive workloads. ArchiveWeights map[string]Weights `yaml:"archive_weights"` // ThroughputWeights is a SECOND, different unit table used only for the diff --git a/harnesses/rpc-cost/cmd/script/model.go b/harnesses/rpc-cost/cmd/script/model.go index 788386210..b9bea3eef 100644 --- a/harnesses/rpc-cost/cmd/script/model.go +++ b/harnesses/rpc-cost/cmd/script/model.go @@ -44,9 +44,14 @@ func unitsPerRequest(p Provider, pr Profile) (float64, error) { return 0, fmt.Errorf("provider does not price %s", pr.Chain) } // A provider with a real archive price list uses it wholesale, and - // the multiplier branch below is skipped. GetBlock's archive cost is - // 3x on debug/trace and 1.5x on the enumerated trace family, so + // the multiplier branch below is skipped. GetBlock is the case that + // forced it: read off its own full_cu / archive_cu columns, archive + // costs 2x on the catch-all reads (Ethereum 20 -> 40) and 3x on the + // enumerated debug, trace and txpool methods (40 -> 120), so // collapsing it to one multiplier would be wrong in both directions. + // The 1.5x that GetBlock's published archive_multiplier implies is a + // property of that unusable formula, not of these columns: see the + // archive_rule note on getblock in pricing/catalogue.yml. usedArchiveTable := false if pr.Archive { if aw, ok := p.ArchiveWeights[pr.Chain]; ok { diff --git a/harnesses/rpc-cost/cmd/script/profiles.go b/harnesses/rpc-cost/cmd/script/profiles.go index 1f71c2419..bbe1bbb3a 100644 --- a/harnesses/rpc-cost/cmd/script/profiles.go +++ b/harnesses/rpc-cost/cmd/script/profiles.go @@ -107,15 +107,11 @@ var Buckets = []struct { {"1000m", 1000e6}, } -// PlanTiers is the plan-band axis. "all" is the unrestricted view (the -// cheapest plan of any tier) and is the bench's default; the rest answer +// PlanTiers is the plan-band axis; every tab other than `all` answers // "which provider is cheapest if I am shopping at this budget level". -// Free is scored here too — it costs $0 wherever it is eligible — but -// the useful free-tier ranking is rpc_free_allowance_requests, since -// every free plan ties at zero. -// The plan-band axis. `all` is the value the bench's queries pin, and the -// site replaces it when a reader picks a tab, so it has to carry the -// default view: the cheapest PAID plan. Including free tiers there would +// `all` is the value the bench's queries pin and the site replaces it +// when a reader picks a tab, so it has to carry the default view: the +// cheapest PAID plan. Including free tiers there would // hand the leaderboard to the $0 rows at the smallest volume and hide the // comparison readers came for; the `free` tab answers that question on // its own axis (rpc_free_allowance_requests), where it belongs. diff --git a/harnesses/rpc-cost/cmd/script/refresh.go b/harnesses/rpc-cost/cmd/script/refresh.go index 836dceeb7..82f0ac4f9 100644 --- a/harnesses/rpc-cost/cmd/script/refresh.go +++ b/harnesses/rpc-cost/cmd/script/refresh.go @@ -57,8 +57,10 @@ func checkArtifact(a Artifact) artifactState { } if a.URL == "" || a.Poll == "manual" { - // Nothing to fetch, but the age still matters: a figure nobody has - // re-read in 90 days is the thing this bench most needs to admit. + // Nothing to fetch, but the age still matters: it is the only thing + // that distinguishes a manually-read figure someone checked this + // week from one nobody has looked at in a year. Published as a + // gauge, not compared to any threshold here. return st } st.polled = true From 96f34b2fdc5d8f589dd9dd2534bba8b126f7d5fc Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:52:36 +0200 Subject: [PATCH 002/187] the tier selector reads its label from the spec, so a cost bench stops saying "Access" (#2733) `dimension_labels` landed with the zero-is-a-value work and every axis reads it except the one that motivated it: venue, bucket, kind, chain and region all do `dimensionLabels?. ?? ""`, while the tier row hardcoded "Access". benchmarks/rpc-cost.yml declares `tier: Billing model` and its tier axis is usage / dedicated / reference ("Billed per request", "Dedicated capacity", "Free public, not ranked"). "Access" is the wrong word for that axis: on rpc-cost the plan band is the `venue` axis, not this one. The page rendered "Access" sixteen times against a spec that had already written the right label. One line, same shape as the five axes beside it. rpc-cost is the only spec in the tree that declares `dimension_labels.tier`, so the nine chain RPC benches keep "Access", which is correct for them: there the tier axis really is public against API-key. typecheck and eslint clean. --- src/components/benchmark-body.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index ff1d55ee8..08932658c 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -817,7 +817,7 @@ export function BenchmarkBody({
{tierOptions.length > 0 && ( Date: Wed, 30 Sep 2026 14:52:45 +0200 Subject: [PATCH 003/187] the chain registry had cosmos-hub twice, so /chains listed it twice (#2734) src/lib/chains.ts carried two `cosmos-hub` entries, identical but for `description`. Nothing threw, because CHAIN_BY_SLUG is `new Map(CHAINS.map(...))` and the later entry simply wins, so the duplicate only showed in the places that iterate the array: - /chains rendered two identical Cosmos Hub cards (verified on production: two `href="/chains/cosmos-hub"` on the page); - the sitemap emitted the URL twice, 1,211 entries for 1,210 unique URLs (verified on an uncached fetch of the live sitemap); - generateStaticParams in src/app/chains/[slug]/page.tsx returned the slug twice, so every build prerendered the route twice; - the stratified smoke test spent one of its 120 tail slots per rotation re-checking a URL it had already checked. The entry removed is the first one, which is the one CHAIN_BY_SLUG was already discarding, so the rendered description does not move: the page serves "The original Cosmos SDK chain and IBC hub..." before and after, checked against the live page. Guard added rather than only the deletion. A duplicate here is invisible to typecheck, to lint and to every Map-based consumer, which is why this one survived; src/lib/chains.test.ts asserts slug uniqueness and that CHAIN_BY_SLUG.size equals CHAINS.length. Checked both ways: the test fails on the pre-fix registry (147 entries, 146 in the Map) and passes after. typecheck, lint and the full suite (371 tests) clean. --- src/lib/chains.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/lib/chains.ts | 8 -------- 2 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 src/lib/chains.test.ts diff --git a/src/lib/chains.test.ts b/src/lib/chains.test.ts new file mode 100644 index 000000000..1be106384 --- /dev/null +++ b/src/lib/chains.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { CHAINS, CHAIN_BY_SLUG } from "@/lib/chains"; + +/** + * The chain registry is iterated directly in places that cannot tolerate a + * repeat: `generateStaticParams` in src/app/chains/[slug]/page.tsx prerenders + * one route per entry, src/app/chains/page.tsx renders one card per entry, and + * src/lib/sitemap-builder.ts emits one per entry. CHAIN_BY_SLUG hides a + * duplicate because the last entry wins the Map, so nothing throws and only + * the rendered output shows it. + * + * It happened: two `cosmos-hub` entries, differing only in `description`, + * shipped a duplicate row on /chains and a 1,211-entry sitemap with 1,210 + * unique URLs. Found by an SEO audit rather than by anything in CI, hence + * this test. + */ +describe("CHAINS registry", () => { + test("every slug is unique", () => { + const seen = new Map(); + for (const c of CHAINS) seen.set(c.slug, (seen.get(c.slug) ?? 0) + 1); + const dupes = [...seen.entries()].filter(([, n]) => n > 1).map(([slug, n]) => `${slug} x${n}`); + expect(dupes).toEqual([]); + }); + + test("CHAIN_BY_SLUG loses no entry, so the Map and the array agree on size", () => { + expect(CHAIN_BY_SLUG.size).toBe(CHAINS.length); + }); + + test("every entry carries the fields the chain hub and the sitemap read", () => { + for (const c of CHAINS) { + expect(c.slug).toMatch(/^[a-z0-9][a-z0-9-]*$/); + expect(c.label.length).toBeGreaterThan(0); + expect(["L1", "L2", "L3"]).toContain(c.category); + } + }); +}); diff --git a/src/lib/chains.ts b/src/lib/chains.ts index 7ba9743c0..2c0944d23 100644 --- a/src/lib/chains.ts +++ b/src/lib/chains.ts @@ -169,14 +169,6 @@ export const CHAINS: ChainEntry[] = [ description: "Ouroboros Proof of Stake, deterministic protocol fees set by governance, EUTXO accounting model.", }, - { - slug: "cosmos-hub", - label: "Cosmos Hub", - category: "L1", - nativeSymbol: "ATOM", - description: - "Cosmos Hub (ATOM). Tendermint / CometBFT consensus, ~6 s block, instant finality, IBC hub for the wider Cosmos ecosystem.", - }, { slug: "litecoin", label: "Litecoin", From a34aa83d54ceb1a1e3a1e5b5b34b5638d8602e3f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:52:54 +0200 Subject: [PATCH 004/187] eleven comments that described something the code does not do, and four dead exports (#2736) Found by an audit of today's diff. Each of these was checked against the code beside it; nothing here changes behaviour. Cache windows that moved and left their prose behind (#2724 widened the routes, these were not resurveyed): - api/stat/[slug] said the worst case went "from about 360 s to about 600 s" and called that parity with the bench page's 600 s ISR. 360 counts stale-while-revalidate on the old header (60 + 300); the matching figure for 300 + 900 is 1200, not 600, so it is about twice the page's ISR, not parity. The comment now says so and records where it shows: /rwa is revalidate = 300 and its FAQ, which becomes FAQPage JSON-LD, tells readers this endpoint carries "the same values and timestamp" while advertising it as a DataDownload. - spec.ts said loadAllBenchmarksCached shares its result "with 60s revalidate" three lines above `revalidate: 300`. Several API routes size their own windows against this number. - benchmark-body.tsx said the variant API "dedupes across users via its 60s cache"; that cache is 300 s. - api/freshness said its edge cache is "5 s s-maxage, 20 s swr" and that the LiveIndicator polls "every 8 s". The response sets s-maxage=30, swr=60 and the widget polls every 30 s. - live-indicator.tsx said it re-fetches /api/citable, edge-cached 60 s. It fetches /api/freshness, and that is 30 s. Both now note the asymmetry this creates: at 30 s the widget is the freshest clock on the page and can read newer than the table under it. - api/aggregate said "The ISR revalidate=60 above handles freshness instead". There is no revalidate on that route; it is force-dynamic. Wrong reason, right conclusion: - next.config.ts said the sitemap sets its own Cache-Control "because metadata routes ignore next.config headers()". sitemap.xml/route.ts is a Route Handler, not a metadata route, and the same comment already gives the real reason two lines earlier. Docs separated from their fields by today's zero_is_a_value insertion, so each described its neighbour. Reordered, text verbatim: - spec-schema.ts: three docs stacked in the reverse order of the three fields, so zero_is_a_value showed ledger_reliability's doc and ledger_columns' doc was stranded two fields up. - types/benchmark.ts: dimensionLabels' doc sat on zeroIsAValue. Counts that no longer hold: - site-footer.tsx named /llms.txt and /api/citable as the links absent from the sitemap, which reads as a complete list. Four of the ten are absent: those two plus /api/openapi.json and /api/llm-context. The next person pruning that row needs all four. - capital-hub.ts and capital/page.tsx both list five benches; eight feed the hub. Both now point at CAPITAL_BENCHES instead of carrying a copy, because the copy went stale the first time the list grew. - capital-hub.ts also credited bench 265 for perp open interest while correctly naming 265 as perp-pf-ratio two lines later. 265 stopped feeding that column today; the block above perpOi explains why. - capital-hub-rules.ts said "n/a" is the missing value on "the other 37 places the site uses it". Not derivable: the literal appears 63 times in src/ across 21 files. The claim keeps its substance and drops the number rather than standing as a fact-shaped guess. - the COLUMN_COVERAGE note quotes the owner's "two rows of seventy-nine" from the first hub draft. Kept as the quotation it is, with the current cohort size (63) named beside it so the two figures stop reading as a contradiction. One reader-facing string, not a comment: the categoryTooSmall reason said a category under five ranked protocols "compares the token with one or two others". Below a floor of five it is up to three others. Dead exports removed from capital-hub-rules.ts, zero callers across src/, scripts/ and worker/: - change7dFromDays and HISTORY_FRESH_MS (its only reader). Today's perp open-interest change removed the single call site, and the doc still named the data path the hub stopped reading. - change7dFromSeries, a one-line wrapper over change7dOfSeries. Its test covers change7dOfSeries' real behaviour, so the test was retargeted at that function rather than deleted: every assertion is kept. - the NaReason type, one reference in the tree and it was its own declaration. Using it instead of deleting it was the alternative, and it is a refactor rather than a cleanup: CCTP_SCOPE_LABEL values are also assigned to the notApplicable fields and are not in NA_REASON, so tightening those to NaReason needs a decision about the union first. typecheck, lint, validate (241 specs) and the suite (367 tests) clean. Deliberately not in this sweep: the warm-up and smoke comments in prod-deploy.yml, staging-deploy.yml and scripts/sitemap-smoke.mjs, which belong to whoever is holding that change; page-metadata.ts's "fallback" comment, which is only wrong because of a behaviour bug fixed separately; and benchmarks/rpc-cost.yml's "one plan in seven", which is page copy whose denominator two passes disagreed on. --- next.config.ts | 11 ++++--- src/app/api/aggregate/route.ts | 6 ++-- src/app/api/freshness/route.ts | 13 ++++---- src/app/api/stat/[slug]/route.ts | 19 +++++++++--- src/app/capital/page.tsx | 7 +++-- src/components/benchmark-body.tsx | 2 +- src/components/live-indicator.tsx | 10 +++++-- src/components/site-footer.tsx | 12 ++++++-- src/lib/capital-hub-rules.test.ts | 31 +++++-------------- src/lib/capital-hub-rules.ts | 49 ++++++------------------------- src/lib/capital-hub.ts | 18 ++++++++---- src/lib/spec-schema.ts | 26 ++++++++-------- src/lib/spec.ts | 4 ++- src/types/benchmark.ts | 4 +-- 14 files changed, 103 insertions(+), 109 deletions(-) diff --git a/next.config.ts b/next.config.ts index 79e482667..6922f3750 100644 --- a/next.config.ts +++ b/next.config.ts @@ -192,10 +192,13 @@ const nextConfig: NextConfig = { // 2026-09-30, the edge entry for /api/citable went STALE at age // 306 and reset to 42, then went STALE again at 313: two clean // cycles, so the served window is that route's 300 s and not a - // one-off eviction. Change the window in the route, not here. Sitemap's - // Cache-Control is likewise set inside its route.ts - // (`src/app/sitemap.xml/route.ts`) because metadata routes - // ignore next.config headers(). + // one-off eviction. Change the window in the route, not here. + // The sitemap sets its Cache-Control the same way and for the same + // reason: `src/app/sitemap.xml/route.ts` is a Route Handler that + // returns its own Response, so whatever it sets wins over this + // layer. (It is a Route Handler rather than a `sitemap.ts` + // metadata route precisely because a metadata route left Next in + // charge of the header; the file's own docblock has that history.) source: "/api/citable", headers: [ { diff --git a/src/app/api/aggregate/route.ts b/src/app/api/aggregate/route.ts index 6500c8b28..c8deae48e 100644 --- a/src/app/api/aggregate/route.ts +++ b/src/app/api/aggregate/route.ts @@ -42,8 +42,10 @@ export async function GET() { headers: { "Accept-Encoding": "gzip, br" }, // No cache:"no-store" here: that flag opts the route into // dynamic mode and causes Next.js to strip s-maxage from our - // response headers, breaking Vercel CDN caching. The ISR - // revalidate=60 above handles freshness instead. + // response headers, breaking Vercel CDN caching. There is no ISR + // revalidate on this route to fall back on (it is force-dynamic, + // line 18); freshness comes from the s-maxage=300 the response + // below sets, which is what the edge honours. }); } catch (err) { lastErr = String(err); diff --git a/src/app/api/freshness/route.ts b/src/app/api/freshness/route.ts index c72e7dbb4..f2584ced9 100644 --- a/src/app/api/freshness/route.ts +++ b/src/app/api/freshness/route.ts @@ -11,11 +11,14 @@ export const runtime = "nodejs"; * (`scalar(time() - max(timestamp()))`), returning just the * resolved data timestamp per slug. * - * Separate from /api/citable so the LiveIndicator can poll fast (every - * 8 s) without re-running the heavy spec → rankings → sparkline → series - * pipeline. Edge-cache is short (5 s s-maxage, 20 s swr) so the visible - * staleness lands around 15-20 s p99 - close to the Prom scrape interval - * floor (15 s) that bounds how fresh any client-side query can ever be. + * Separate from /api/citable so the LiveIndicator can poll cheaply + * without re-running the heavy spec → rankings → sparkline → series + * pipeline. It polls every 30 s (live-indicator.tsx) against a 30 s + * s-maxage and 60 s swr, so the visible staleness lands within about a + * minute, above the Prom scrape interval floor (15 s) that bounds how + * fresh any client-side query can ever be. This is the shortest window on + * the site, which is why the widget can read newer than the table beneath + * it. */ // Cache window has to stay STRICTLY shorter than the LiveIndicator poll diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts index 005565cc1..bef456762 100644 --- a/src/app/api/stat/[slug]/route.ts +++ b/src/app/api/stat/[slug]/route.ts @@ -183,10 +183,21 @@ export async function GET( // 300 s, not 60: getBenchmark reads through the bench data cache // (src/lib/spec.ts, revalidate 300), so four of every five fills at // a 60 s window recomputed this payload and returned the same bytes. - // The two windows do compose, so this is not free: worst-case data - // age goes from about 360 s to about 600 s. That is parity with the - // 600 s ISR on the bench page this mirrors, which is the number a - // reader comparing the two would see anyway. + // + // The two windows compose, and stale-while-revalidate is part of the + // window the edge actually serves: 60 + 300 was a worst case of + // about 360 s, 300 + 900 is about 1200 s, plus the 300 s data cache + // under it. So this is not parity with the 600 s ISR on the bench + // page; it is roughly twice it, and an earlier version of this + // comment claimed parity by counting swr on the old header and not + // on the new one. + // + // Where that shows: /rwa is revalidate = 300 and its FAQ (which + // becomes FAQPage JSON-LD) tells readers this endpoint carries "the + // same values and timestamp" as the page, while advertising it as a + // DataDownload. An agent that follows that link can get a body older + // than the stamp the page showed it. Narrow the window here, or + // soften that sentence there, before treating the two as identical. "cache-control": "public, s-maxage=300, stale-while-revalidate=900", "access-control-allow-origin": "*", }, diff --git a/src/app/capital/page.tsx b/src/app/capital/page.tsx index 636e1b1f3..66574c0f4 100644 --- a/src/app/capital/page.tsx +++ b/src/app/capital/page.tsx @@ -13,8 +13,11 @@ import { buildCitationMeta, CREATOR_PUBLISHER, DATASET_LICENSE } from "@/lib/dat * (TVL, bridged value, stablecoin flows, open interest) and how tokens are * priced against the fees their protocols earn (P/F, P/S, FDV, float, fee * trend against token move). Everything is read from the materialized - * benches 273, 275, 274, 265 and 277 plus the two daily history blobs the - * worker publishes; see src/lib/capital-hub.ts. + * benches plus the two daily history blobs the worker publishes. The bench + * list is CAPITAL_BENCHES in src/lib/capital-hub-types.ts, eight of them + * today (273, 274, 275, 265, 277, 271, 280, 281); read it there rather + * than from a copy here, because this copy went stale the first time the + * list grew. See src/lib/capital-hub.ts for how each is used. * * The page names numbers and the peers they are compared against. It does * not tell the reader what to do with them: the neutral wording is the diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 08932658c..fbbb8d7c5 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -492,7 +492,7 @@ export function BenchmarkBody({ // + first render), which the user otherwise eats as a long dimmed // state after clicking a tab. Warming them right after mount turns // tab flips into in-memory swaps. Staggered 400ms apart to stay - // gentle; the variant API dedupes across users via its 60s cache, + // gentle; the variant API dedupes across users via its 300s cache, // and re-runs when the user settles on a new axis value so the // cross-axis re-warms. useEffect(() => { diff --git a/src/components/live-indicator.tsx b/src/components/live-indicator.tsx index f856f97d2..31986483c 100644 --- a/src/components/live-indicator.tsx +++ b/src/components/live-indicator.tsx @@ -12,9 +12,13 @@ import { useEffect, useState } from "react"; * Every page that renders this widget has its own ISR cycle, so the SSR * `lastRunAt` can drift across pages by a few seconds (home was rendered * at one cache miss, detail at another). To present a single canonical - * value site-wide, when a `slug` is passed we re-fetch /api/citable on - * mount and replace the SSR value with the API value. /api/citable is - * edge-cached for 60 s so the network cost is amortised across viewers. + * value site-wide, when a `slug` is passed we re-fetch /api/freshness on + * mount and replace the SSR value with the API value. That route is + * edge-cached for 30 s (s-maxage=30, stale-while-revalidate=60) so the + * network cost is amortised across viewers. Note the asymmetry: at 30 s + * this widget is the freshest clock on the page, so it can read newer + * than the table under it, whose figures come from the page's own ISR + * window and from per-panel routes on 300 s. */ export function LiveIndicator({ lastRunAt, diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index 611aaf95a..7aa571304 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -15,9 +15,15 @@ import { SITE } from "@/data/site"; * What did NOT change is the link set. None of these ten appear in the * left rail: the rail carries the editorial sections (/benchmarks, * /perps, /rpc and so on) and this carries the developer and project - * surfaces. /llms.txt and /api/citable are not in the sitemap either, so - * this footer is their only inbound link and dropping it would orphan the - * two files the whole citability effort exists to serve. + * surfaces. + * + * Before pruning this row, check the sitemap. FOUR of these ten are + * absent from it, so for each of them this footer is the only inbound + * link on the site: /llms.txt, /api/citable, /api/openapi.json and + * /api/llm-context. Dropping any one orphans a file the citability work + * exists to serve. (/partners, /badges, /team and /press are in the + * sitemap and would survive a cut here.) An earlier version of this note + * named only the first two, which read as a complete list and was not. */ const DEVELOPER_LINKS = [ { label: "Partners + embeds", href: "/partners" }, diff --git a/src/lib/capital-hub-rules.test.ts b/src/lib/capital-hub-rules.test.ts index b65115fc0..c077c5e5c 100644 --- a/src/lib/capital-hub-rules.test.ts +++ b/src/lib/capital-hub-rules.test.ts @@ -9,8 +9,6 @@ import { SIGNAL_READING, bridgedShareSubline, cctpScope, - change7dFromDays, - change7dFromSeries, categoryMedianUsable, change7dOfSeries, cohortCell, @@ -217,39 +215,24 @@ describe("divergences", () => { }); describe("7d change", () => { - test("from daily history: only once the day seven days back exists and the newest day is current", () => { - const days = [ - { day: "2026-09-18", oi: 100 }, - { day: "2026-09-19", oi: 105 }, - { day: "2026-09-25", oi: 120 }, - ]; - const now = Date.parse("2026-09-25T18:00:00Z"); - expect(change7dFromDays(days, "oi", now)).toBeCloseTo(20, 6); - // Yesterday's point is still current; a point three days old is a stalled worker. - expect(change7dFromDays(days, "oi", Date.parse("2026-09-26T23:00:00Z"))).toBeCloseTo(20, 6); - expect(change7dFromDays(days, "oi", Date.parse("2026-09-28T01:00:00Z"))).toBeNull(); - expect(change7dFromDays(days.slice(1), "oi", now)).toBeNull(); - expect(change7dFromDays([], "oi", now)).toBeNull(); - expect(change7dFromDays([{ day: "2026-09-25", oi: 120 }], "oi", now)).toBeNull(); - }); test("from a bench series: first and last finite buckets, and only when the series covers the whole window", () => { const covered = [100, null, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200]; - expect(change7dFromSeries(covered)).toBeCloseTo(100, 6); + expect(change7dOfSeries(covered).value).toBeCloseTo(100, 6); // A bench that started this week: the first finite bucket sits past the first tenth of the window. const late = [null, null, null, null, null, null, 150, 160, 170, 180, 190, 200]; - expect(change7dFromSeries(late)).toBeNull(); + expect(change7dOfSeries(late).value).toBeNull(); // About 75% coverage is not seven days either. const threeQuarters = Array.from({ length: 84 }, (_, i) => (i < 21 ? null : 100 + i)); - expect(change7dFromSeries(threeQuarters)).toBeNull(); + expect(change7dOfSeries(threeQuarters).value).toBeNull(); // A step of more than 2x between two adjacent buckets is a change in what the bench // measures (Kalshi's open interest went from $71M to $1.02B in one bucket this week): no 7d figure. const step = Array.from({ length: 84 }, (_, i) => (i < 40 ? 71_000_000 + i * 10_000 : 1_010_000_000 + i * 10_000)); - expect(change7dFromSeries(step)).toBeNull(); + expect(change7dOfSeries(step).value).toBeNull(); // A large but continuous move stays: +80% over the week in small increments. const steady = Array.from({ length: 84 }, (_, i) => 100 * (1 + (0.8 * i) / 83)); - expect(change7dFromSeries(steady)).toBeCloseTo(80, 6); - expect(change7dFromSeries(undefined)).toBeNull(); - expect(change7dFromSeries([null, null])).toBeNull(); + expect(change7dOfSeries(steady).value).toBeCloseTo(80, 6); + expect(change7dOfSeries(undefined).value).toBeNull(); + expect(change7dOfSeries([null, null]).value).toBeNull(); }); test("an empty 7d cell says which condition failed, so the table never prints a bare dash for a known reason", () => { expect(change7dOfSeries(undefined).naReason).toBe(NA_REASON.oiSeriesMissing); diff --git a/src/lib/capital-hub-rules.ts b/src/lib/capital-hub-rules.ts index 902dc84ef..61399c902 100644 --- a/src/lib/capital-hub-rules.ts +++ b/src/lib/capital-hub-rules.ts @@ -82,11 +82,11 @@ export function bridgedShareSubline(sharePct: number | null): string | null { * withheld row, a bench that failed to load this render). The cell * prints "n/a" and nothing else. * - * "n/a" is the missing value here because it is the missing value on the - * other 37 places the site uses it (perp head to head, the trading-app - * charts, the RPC tables, the fee comparison), and a token cannot mean two - * things on two pages. The inapplicable case gets its own rendering instead, - * as it already does on the fee comparison page. + * "n/a" is the missing value here because it is the missing value + * everywhere else on the site (perp head to head, the trading-app charts, + * the RPC tables, the fee comparison), and a token cannot mean two things + * on two pages. The inapplicable case gets its own rendering instead, as it + * already does on the fee comparison page. * * This is the bench 208 rule applied to a table: an absent measurement must * not read as a measured absence. @@ -127,11 +127,9 @@ export const NA_REASON = { oiSeriesShort: "the 7-day window is not covered: the series starts inside it", oiSeriesFlat: "fewer than two readings in the 7-day window", oiSeriesStep: "the series steps more than 2x inside the window: a change in what the venue reports, not a 7-day move", - categoryTooSmall: "category too small for a peer median: under five ranked protocols, the ratio compares the token with one or two others", + categoryTooSmall: "category too small for a peer median: under five ranked protocols, the ratio compares the token with at most three others", } as const; -export type NaReason = (typeof NA_REASON)[keyof typeof NA_REASON]; - export function cohortCell( inCohort: boolean, ranked: number | null, @@ -315,31 +313,6 @@ export function selectDivergences(rows: T[], limi /* ------------------------------------------------------- 7d change */ -/** A daily history counts as current while its newest day is today or yesterday by the clock (UTC), not by the blob's own stamp. */ -export const HISTORY_FRESH_MS = 2 * 86_400_000; - -/** - * Percent change between the newest point and the point seven days before - * it in a daily history (the valuation blob's `oi` per perp). Null until - * the blob holds that older day, and null when the newest day is older - * than `nowMs` minus two days: no shorter window is passed off as 7d and - * a stalled worker publishes no change. - */ -export function change7dFromDays(days: { day: string; [k: string]: unknown }[], field: string, nowMs: number = Date.now()): number | null { - if (days.length === 0) return null; - const last = days[days.length - 1]; - const lastV = last[field]; - if (typeof lastV !== "number" || !Number.isFinite(lastV)) return null; - const t = Date.parse(`${last.day}T00:00:00Z`); - if (!Number.isFinite(t)) return null; - if (nowMs - t > HISTORY_FRESH_MS) return null; - const target = new Date(t - 7 * 86_400_000).toISOString().slice(0, 10); - const before = days.find((d) => d.day === target); - const beforeV = before?.[field]; - if (typeof beforeV !== "number" || !Number.isFinite(beforeV) || beforeV <= 0) return null; - return ((lastV - beforeV) / beforeV) * 100; -} - /** Largest bucket-to-bucket ratio a 7d series may carry and still read as one continuous window. */ export const SERIES_STEP_MAX = 2; @@ -381,11 +354,6 @@ export function change7dOfSeries(series: (number | null)[] | undefined): { value return { value: ((b - a) / a) * 100, naReason: null }; } -/** The number only, for callers that already know why an empty cell is empty. */ -export function change7dFromSeries(series: (number | null)[] | undefined): number | null { - return change7dOfSeries(series).value; -} - /* --------------------------------------------------- reading notes */ /** @@ -536,8 +504,9 @@ export function signalRowNote(r: { * Whether an optional column is worth a place in the table: at least half the * rows carry a value. A column filled for two rows of seventy-nine paints the * table with empty cells and says nothing (the owner's note on the first hub - * draft), so a source still filling in, or one a rate limit keeps mostly - * empty, stays hidden until it covers the cohort. + * draft, whose chain cohort was seventy-nine rows; it is 63 today), so a + * source still filling in, or one a rate limit keeps mostly empty, stays + * hidden until it covers the cohort. * * Every optional column of the chains table goes through this, on the value * that would actually print (`printedValue`), so a column like net USDC over diff --git a/src/lib/capital-hub.ts b/src/lib/capital-hub.ts index 8c4d05a6b..10311bf6b 100644 --- a/src/lib/capital-hub.ts +++ b/src/lib/capital-hub.ts @@ -5,17 +5,25 @@ * bench pages render) plus the two daily history blobs the worker writes * (src/lib/capital-history.ts). No Prometheus call at render time. * - * Two cohorts: + * Two cohorts (the full bench list is CAPITAL_BENCHES in + * src/lib/capital-hub-types.ts, eight entries): * - follow the capital: chains, from bench 273 chain-bridged-tvl (L2Beat * value secured), bench 275 chain-stablecoin-flow (DeFiLlama stablecoin - * float per chain) and the chains history blob (TVL, native mcap, DEX - * volume, chain fees and revenue when the chain-kpis harness publishes - * them); open interest from bench 265 (perp DEX OI) and bench 277 - * (prediction markets). + * float per chain), bench 280 chain-fees-revenue, bench 281 + * usdc-corridor-flows (net USDC over CCTP) and the chains history blob + * (TVL, native mcap, DEX volume, chain fees and revenue when the + * chain-kpis harness publishes them); open interest from the perp venue + * cohort gauges with turnover from bench 271 perp-volume-oi-ratio, and + * from bench 277 pm-open-interest for prediction markets. * - valuation: bench 274 protocol-pf-ratio (cross-DeFi P/F, fees MoM, * token 30d, category median) and bench 265 perp-pf-ratio (P/F, P/S, * FDV, float, OI, fees and revenue). * + * Perp open interest is NOT bench 265's `oi` panel. It was until + * 2026-09-30; the block above perpOi below says why it moved and what + * disagreed. Bench 265 is named once here, as perp-pf-ratio, and that is + * the only thing it feeds. + * * Every field is independently nullable, and an empty cell says which kind * of empty it is: a column that does not apply to the row carries the * reason why (`notApplicable`, from the cohort flags the benches publish), diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index a2bb57c97..8e90e6dfe 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -722,19 +722,6 @@ export const SpecSchema = z .max(17) .optional(), - /** - * Optional relabeling of the ledger's aggregate columns. For benches - * whose unit has no percentile semantics (USD revenue leaderboards), - * the p50/p90/p99/mean slots are repurposed; declaring ledger_columns - * renders each column with an honest label and unit instead of the - * default latency headers. `slot` reads the provider's headline slot, - * `panel` reads the values of a metric_panels entry by id. The first - * column is the headline (sort key, data bar, mobile column). - */ - /** false when the providers' `success` query is a publication gate - * (0 or 1) rather than a probe success rate: the ledger then hides - * the Success and Reliability columns and describes an unpublished - * row as under its threshold, not as a dead endpoint. */ /** * Whether 0 is a real value on this bench rather than a missing one. * @@ -747,7 +734,20 @@ export const SpecSchema = z * Defaults false, so every existing bench keeps the behaviour it has. */ zero_is_a_value: z.boolean().default(false), + /** false when the providers' `success` query is a publication gate + * (0 or 1) rather than a probe success rate: the ledger then hides + * the Success and Reliability columns and describes an unpublished + * row as under its threshold, not as a dead endpoint. */ ledger_reliability: z.boolean().default(true), + /** + * Optional relabeling of the ledger's aggregate columns. For benches + * whose unit has no percentile semantics (USD revenue leaderboards), + * the p50/p90/p99/mean slots are repurposed; declaring ledger_columns + * renders each column with an honest label and unit instead of the + * default latency headers. `slot` reads the provider's headline slot, + * `panel` reads the values of a metric_panels entry by id. The first + * column is the headline (sort key, data bar, mobile column). + */ ledger_columns: z .array( z diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 33899d077..291209776 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -619,7 +619,9 @@ export async function aggregateBenchmarks( // Promise.allSettled so a single bench's transient throw doesn't bring // down the whole list. See aggregateBenchmarks for the actual logic. // This wrapper layers unstable_cache on top so the result is shared -// across requests with 60s revalidate. +// across requests with 300s revalidate (the option is on the call below; +// several API routes size their own edge windows against this number, so +// the two have to be read together). const loadAllBenchmarksCached = unstable_cache( async (): Promise => { const specs = await loadSpecs(); diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 5e3b75149..064d655f9 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -298,11 +298,11 @@ export type Benchmark = { * injecting a PromQL label; the first value is the headline cohort. */ tier?: { value: string; label: string }[]; }; - /** Per-bench axis labels for the dimension selectors, from the spec's - * `dimension_labels`. Absent keys keep the UI's stock label. */ /** True when 0 is a real value on this bench (a free tier costing * nothing) rather than a missing one (a dead latency probe). */ zeroIsAValue?: boolean; + /** Per-bench axis labels for the dimension selectors, from the spec's + * `dimension_labels`. Absent keys keep the UI's stock label. */ dimensionLabels?: { chain?: string; region?: string; From 81dd013b80a339d562a966d1dff3a920f59e883b Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:53:02 +0200 Subject: [PATCH 005/187] 28 hub share cards were being generated and never used (#2738) pageMetadata pinned openGraph.images and twitter.images to the ROOT /opengraph-image and /twitter-image. The comment called it a fallback for hubs without a dedicated card. It was an unconditional override, and config-based images beats the file convention, so every page built through this helper shared one card. 28 of the 30 callers ship their own opengraph-image.tsx. Every one of them was built, is served at its own URL and was named in no meta tag: checked on production, where /perps/opengraph-image, /rpc/opengraph-image and /team/opengraph-image all answer 200 with a distinct PNG while the pages themselves advertise the root one. /capital shipped a 78 KB card this week that nothing has ever pointed at. Dropping the two images keys lets Next resolve the card from the route's own file, and it fills twitter:image from that same file when the route has no twitter-image.tsx, so both tags land on the right card with its real alt text and dimensions. Two things this turned up that the plan had wrong, both found by rendering the pages rather than by reading the precedence rules: There is no ancestor cascade. A route with no opengraph-image.tsx of its own emits NO og:image once the override is gone, the root file notwithstanding. /rwa, /rpc-map and /perps/[asset] are the three callers in that position, so each gains a file. /rwa and /rpc-map re-export the site card, which is byte for byte what they served before (62,240 B), so those two are dedupe with no visible change; /perps/[asset] takes the perps hub card, which is the right subject and an upgrade on the generic one. Route segment config cannot be re-exported. `export { revalidate } from ...` fails with "can't recognize the exported `revalidate` field in route. It mustn't be reexported", and it does not fail locally: it 500s every image route in the app, the root card included. runtime and revalidate are declared literally in the three new files, with a note saying why. Verified in dev after the change: /rwa, /rpc-map, /perps/eth, /capital, /team and /rpc each emit og:image and twitter:image pointing at their own segment, and all five image routes answer 200 image/png (/capital 78,895 B, /perps/eth 66,985 B, /rwa and /rpc-map 62,240 B). typecheck, lint and the suite (368 tests) clean. --- src/app/perps/[asset]/opengraph-image.tsx | 20 +++++++++++++++ src/app/rpc-map/opengraph-image.tsx | 20 +++++++++++++++ src/app/rwa/opengraph-image.tsx | 20 +++++++++++++++ src/lib/page-metadata.ts | 30 +++++++++++++++++------ 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/app/perps/[asset]/opengraph-image.tsx create mode 100644 src/app/rpc-map/opengraph-image.tsx create mode 100644 src/app/rwa/opengraph-image.tsx diff --git a/src/app/perps/[asset]/opengraph-image.tsx b/src/app/perps/[asset]/opengraph-image.tsx new file mode 100644 index 000000000..5791ba125 --- /dev/null +++ b/src/app/perps/[asset]/opengraph-image.tsx @@ -0,0 +1,20 @@ +/** + * Share card for this route, re-exported rather than drawn again. + * + * It has to exist as a file. Next resolves opengraph-image from the route + * segment's own file and does NOT inherit one from an ancestor segment, so + * without this the route emits no og:image at all: verified in dev, where + * /rwa, /rpc-map and /perps/ rendered with no image meta once + * pageMetadata stopped pinning `images` by hand. Deleting this file does not + * fall back to ../opengraph-image, it falls back to nothing. + * + * The perps hub card: a per-asset perp view is the same subject, so this is an upgrade on the generic site card it showed before. + * + * `runtime` and `revalidate` are route segment config and are declared here + * literally: Next refuses a re-exported one ("can't recognize the exported + * `revalidate` field in route. It mustn't be reexported"), and the failure is + * a 500 on every image route in the app, not just this one. + */ +export const runtime = "nodejs"; +export const revalidate = 86400; +export { default, alt, size, contentType } from "../opengraph-image"; diff --git a/src/app/rpc-map/opengraph-image.tsx b/src/app/rpc-map/opengraph-image.tsx new file mode 100644 index 000000000..3c1aad37a --- /dev/null +++ b/src/app/rpc-map/opengraph-image.tsx @@ -0,0 +1,20 @@ +/** + * Share card for this route, re-exported rather than drawn again. + * + * It has to exist as a file. Next resolves opengraph-image from the route + * segment's own file and does NOT inherit one from an ancestor segment, so + * without this the route emits no og:image at all: verified in dev, where + * /rwa, /rpc-map and /perps/ rendered with no image meta once + * pageMetadata stopped pinning `images` by hand. Deleting this file does not + * fall back to ../opengraph-image, it falls back to nothing. + * + * The site card, which is what this page served before. + * + * `runtime` and `revalidate` are route segment config and are declared here + * literally: Next refuses a re-exported one ("can't recognize the exported + * `revalidate` field in route. It mustn't be reexported"), and the failure is + * a 500 on every image route in the app, not just this one. + */ +export const runtime = "nodejs"; +export const revalidate = 86400; +export { default, alt, size, contentType } from "../opengraph-image"; diff --git a/src/app/rwa/opengraph-image.tsx b/src/app/rwa/opengraph-image.tsx new file mode 100644 index 000000000..9abfd00de --- /dev/null +++ b/src/app/rwa/opengraph-image.tsx @@ -0,0 +1,20 @@ +/** + * Share card for this route, re-exported rather than drawn again. + * + * It has to exist as a file. Next resolves opengraph-image from the route + * segment's own file and does NOT inherit one from an ancestor segment, so + * without this the route emits no og:image at all: verified in dev, where + * /rwa, /rpc-map and /perps/ rendered with no image meta once + * pageMetadata stopped pinning `images` by hand. Deleting this file does not + * fall back to ../opengraph-image, it falls back to nothing. + * + * The site card, which is what this hub served before. A bespoke RWA card can replace it here without touching pageMetadata. + * + * `runtime` and `revalidate` are route segment config and are declared here + * literally: Next refuses a re-exported one ("can't recognize the exported + * `revalidate` field in route. It mustn't be reexported"), and the failure is + * a 500 on every image route in the app, not just this one. + */ +export const runtime = "nodejs"; +export const revalidate = 86400; +export { default, alt, size, contentType } from "../opengraph-image"; diff --git a/src/lib/page-metadata.ts b/src/lib/page-metadata.ts index 6d5d230ad..a2c13d97f 100644 --- a/src/lib/page-metadata.ts +++ b/src/lib/page-metadata.ts @@ -33,11 +33,29 @@ export function pageMetadata({ const canonical = `${SITE_ORIGIN}${path}`; const socialUrl = chain && chain !== "all" ? `${canonical}?chain=${chain}` : canonical; const social = title.includes("OpenChainBench") ? title : `${title} · OpenChainBench`; - // Fallback to the root /opengraph-image and /twitter-image when a hub - // doesn't ship a dedicated card. Without this, Next.js treats the - // per-page `openGraph` block as a full override and drops the - // file-based root image, so /compare and /alternatives end up sharing - // unbranded blank cards on every social embed. + // No `images` on either block below, deliberately. + // + // This used to pin `images` to the ROOT /opengraph-image and + // /twitter-image, described as a fallback for hubs without a dedicated + // card. It was not a fallback, it was an unconditional override, and + // config-based `images` beats the file convention: 28 of the 30 routes + // that call this ship their own opengraph-image.tsx, every one of them + // was built and served at its own URL, and not one was ever named in a + // meta tag. Every hub shared the root card instead. + // + // Leaving `images` out lets Next resolve it from the route's own + // opengraph-image.tsx, and it also fills twitter:image from that same + // file when the route has no twitter-image.tsx of its own (checked in + // dev: /team, /capital and /perps each emit both tags pointing at their + // own card, with its real alt text and dimensions). + // + // What it does NOT do is inherit from an ancestor segment. There is no + // cascade: a route with no opengraph-image.tsx of its own emits no + // og:image at all, the root file notwithstanding. Checked the same way, + // and it is why /rwa, /rpc-map and /perps/[asset] each gained a + // one-line re-export file in this change. So every caller of this helper + // needs a file in its own segment; adding a new hub without one ships it + // with no share card and nothing will complain. const meta: Metadata = { // The layout template appends " · OpenChainBench" (17 characters): // past 43 the tail of the title, where the measured number sits, is @@ -52,14 +70,12 @@ export function pageMetadata({ url: socialUrl, type: "website", siteName: "OpenChainBench", - images: [{ url: `${SITE_ORIGIN}/opengraph-image`, width: 1200, height: 630 }], }, twitter: { card: "summary_large_image", title: social, description, site: "@OpenChainBench", - images: [`${SITE_ORIGIN}/twitter-image`], }, }; return meta; From 58039beb9677323f8915aa7b27073e6ca44cef9d Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:53:10 +0200 Subject: [PATCH 006/187] five more capital sections say which kind of nothing they are, and one token matches in the singular (#2739) The perp open-interest section was taught to say so instead of vanishing, and the comment above it states the rule: a table that disappears with nothing said is the same defect as a cell, one level up. Five sections on the same page still vanished: the stablecoin flow bar, the CCTP block, prediction-market open interest, the perp token table, and the protocols block, which takes the divergence heading and the whole reading layer with it. If bench 274 fails to load, the Valuation tab loses its H2, its reading and its table in silence. The data to say it was already there. hub.benches carries live and failed per bench, so sectionState (now in capital-hub-rules.ts with the other shared rules, and tested) tells three cases apart: failed the load threw; the reading exists, this render missed it unserved a bench this deployment does not carry empty served, ran, ranked nobody: the only one that is a measurement and returns null when the bench is not in the list at all, which is the gated case. Benches 280 and 281 are only listed where they are served, and a reading the deployment never promised is not a reading that is missing. Without that clause prod would print a note for the CCTP block it is not supposed to have. MissingSection is one implementation, because the point of the rule is that every section says it the same way; each caller passes what is missing and why the nearest other reading is not a stand-in. The perp OI paragraph keeps its own text: its source is the perp venue cohort snapshot, which has no entry in hub.benches and so no flag to read. The comment there now says that rather than implying it is the only section with the treatment. Separately, the divergence FAQ answer had a zero branch and a plural branch and no singular one, so at one match it read "1 of 67 tokens match today, Moonwell among them". Wrong agreement, and "among them" for one row. Singular is the common case now: the rule selected 11 rows before the two size floors and the category floor, and 1 today. This answer is in the FAQPage JSON-LD, so it is the sentence an answer engine quotes. Verified by rendering. With data present every section is unchanged and the FAQ reads "1 of 67 tokens matches today, Moonwell". With bench 277 forced to failed and its rows emptied, the section renders its heading plus "is unavailable here: the bench behind it did not answer on this render, which is transient and not a retirement", and the stub was reverted. typecheck, lint, validate (241 specs) and the suite (372 tests, 5 new on sectionState) clean. --- src/app/capital/page.tsx | 17 +++- src/components/capital-hub-tabs.tsx | 144 +++++++++++++++++++++++++--- src/lib/capital-hub-rules.test.ts | 36 +++++++ src/lib/capital-hub-rules.ts | 30 ++++++ 4 files changed, 212 insertions(+), 15 deletions(-) diff --git a/src/app/capital/page.tsx b/src/app/capital/page.tsx index 66574c0f4..e8b976602 100644 --- a/src/app/capital/page.tsx +++ b/src/app/capital/page.tsx @@ -365,10 +365,19 @@ function buildFaq(hub: CapitalHub): { q: string; a: string }[] { q: "What does the fees up, token down badge mean?", a: diverging.length > 0 - ? `Four things at once: the protocol's 30-day fees grew more than ${SIGNAL_FEE_GROWTH_MIN_PCT}% against the prior 30 days, its token fell more than ${SIGNAL_PRICE_MOVE_MIN_PCT}% over the same days, its price to fees sits below its category median, and the category holds at least ${SIGNAL_MIN_CATEGORY_MEMBERS} ranked protocols. ${diverging.length} of ${hub.protocols.length} tokens match today, ${diverging - .slice(0, 3) - .map((r) => r.name) - .join(", ")} among them. The two size thresholds keep a flat token and a fee month in line with the field off the list, and the category floor keeps a median over one or two peers from qualifying a row. It is a screen for further reading, not a signal to act on.` + ? `Four things at once: the protocol's 30-day fees grew more than ${SIGNAL_FEE_GROWTH_MIN_PCT}% against the prior 30 days, its token fell more than ${SIGNAL_PRICE_MOVE_MIN_PCT}% over the same days, its price to fees sits below its category median, and the category holds at least ${SIGNAL_MIN_CATEGORY_MEMBERS} ranked protocols. ${ + // Singular is the common case now the rule is four clauses with two + // size floors: it selected 11 rows before them and 1 today. "1 of 67 + // tokens match today, X among them" was the previous wording, and + // this answer is in the FAQPage JSON-LD, so it is the sentence an + // answer engine quotes. + diverging.length === 1 + ? `1 of ${hub.protocols.length} tokens matches today, ${diverging[0].name}` + : `${diverging.length} of ${hub.protocols.length} tokens match today, ${diverging + .slice(0, 3) + .map((r) => r.name) + .join(", ")} among them` + }. The two size thresholds keep a flat token and a fee month in line with the field off the list, and the category floor keeps a median over one or two peers from qualifying a row. It is a screen for further reading, not a signal to act on.` : `Four things at once: the protocol's 30-day fees grew more than ${SIGNAL_FEE_GROWTH_MIN_PCT}% against the prior 30 days, its token fell more than ${SIGNAL_PRICE_MOVE_MIN_PCT}% over the same days, its price to fees sits below its category median, and the category holds at least ${SIGNAL_MIN_CATEGORY_MEMBERS} ranked protocols. No token matches all four today.`, }, { diff --git a/src/components/capital-hub-tabs.tsx b/src/components/capital-hub-tabs.tsx index 19a5cdab6..bd1bed2de 100644 --- a/src/components/capital-hub-tabs.tsx +++ b/src/components/capital-hub-tabs.tsx @@ -33,9 +33,11 @@ import { naMarker, plainCell, printedValue, + sectionState, sevenDaySubline, signalRowNote, type CohortCell, + type SectionState, type SignalKind, } from "@/lib/capital-hub-rules"; @@ -59,6 +61,13 @@ export function CapitalHubTabs({ hub }: { hub: CapitalHub }) { // reading and too narrow for a column on a 63-row table, so it gets its own // block over the chains it covers instead of 56 markers across the page. const cctpRows = hub.chains.filter((c) => c.cctpScope === "scanned" && c.cctpNet7d != null); + // Which sections have nothing to show, and which kind of nothing. Computed + // once here so the JSX below reads as "rows, or the reason there are none". + const flowState = sectionState(hub.benches, CAPITAL_BENCHES.stableFlow, hub.stableFlowShares.length); + const cctpState = sectionState(hub.benches, CAPITAL_BENCHES.usdcCorridor, cctpRows.length); + const pmOiState = sectionState(hub.benches, CAPITAL_BENCHES.pmOi, hub.pmOi.length); + const protocolsState = sectionState(hub.benches, CAPITAL_BENCHES.protocolPf, hub.protocols.length); + const perpsState = sectionState(hub.benches, CAPITAL_BENCHES.perpPf, hub.perps.length); return ( <> @@ -85,17 +94,51 @@ export function CapitalHubTabs({ hub }: { hub: CapitalHub }) {

Chains ranked by capital: TVL, bridged value, stablecoin flows

{hub.chains.length > 0 && } {hub.chains.length > 0 && } - {hub.stableFlowShares.length > 0 && } - {cctpRows.length > 0 && } + {hub.stableFlowShares.length > 0 ? ( + + ) : ( + flowState && ( + + The net column in the table above is the same measurement per row; this bar only + apportions it, so read it there. + + } + /> + ) + )} + {cctpRows.length > 0 ? ( + + ) : ( + cctpState && ( + + It covers the seven chains bench {CAPITAL_BENCHES.usdcCorridor} scans as sources and is + never inferred from the bridged or stablecoin columns, which measure something else. + + } + /> + ) + )} {(hub.perpOi.length > 0 || hub.pmOi.length > 0 || !hub.perpVenueCohortLive) && ( <>

Open interest: perp DEXes and prediction markets

- {/* The perp table reads the venue cohort snapshot. When that does - not answer, the table has no rows, and a table that vanishes - with nothing said is the defect this page spent a week - removing, one level up from a cell. So the section says which - source is missing and where the last measurements are, and it - never fills in from another measurement. */} + {/* A table that vanishes with nothing said is the defect this page + spent a week removing, one level up from a cell, and every + section on the page now says which kind of nothing it is: + sectionState + MissingSection carry the bench-backed ones + (flow bar, CCTP, prediction-market OI, both valuation tables). + This one keeps its own paragraph because its source is not a + bench: it reads the perp venue cohort snapshot, which has no + entry in hub.benches and so no live/failed flag to read. Same + rule, different source, and it never fills in from another + measurement. */} {!hub.perpVenueCohortLive && (

Perp DEX open interest is unavailable on this render: the perp venue cohort snapshot did not answer. Nothing is substituted for it, @@ -124,7 +167,7 @@ export function CapitalHubTabs({ hub }: { hub: CapitalHub }) { note="Open interest and 24h volume as each venue's own API reports them, through the perp cohort harness, the same figures the perps hub shows. Turnover is bench 271's ratio of 24-hour averages of those two gauges, so it will not divide exactly into the two columns beside it, which are the latest read. Centralised venues are out; Polymarket and Kalshi sit in the prediction-market table rather than twice on one page." /> )} - {hub.pmOi.length > 0 && ( + {hub.pmOi.length > 0 ? ( + ) : ( + pmOiState && ( +

+

Prediction market open interest

+ + The perp table beside it is a different cohort measured from different feeds, so it + is not a stand-in. + + } + /> +
+ ) )}
{(hub.perpOi.length > 0 || hub.pmOi.length > 0) && } @@ -140,8 +199,26 @@ export function CapitalHubTabs({ hub }: { hub: CapitalHub }) { )} - {hasValuation && ( + {(hasValuation || protocolsState || perpsState) && ( )} @@ -824,6 +917,35 @@ function PerpsTable({ rows }: { rows: PerpRow[] }) { /* ------------------------------------------------------------------ */ /** Three lines under a table: how to read the main column, the pitfall, what the table does not say. */ +/** + * The paragraph a section renders instead of vanishing. One implementation, + * because the whole point of the rule is that every section says it the same + * way; `extra` carries whatever is specific to the reading that is missing. + */ +function MissingSection({ + state, + what, + extra, +}: { + state: SectionState; + what: string; + extra?: React.ReactNode; +}) { + const because = + state === "failed" + ? "the bench behind it did not answer on this render, which is transient and not a retirement" + : state === "unserved" + ? "the bench behind it is not served by this deployment" + : "the bench behind it ran and ranked no rows this time"; + return ( +

+ {what} is unavailable here: {because}. Nothing is substituted for it, + because the nearest other reading on this page would be a different + measurement.{extra ? <> {extra} : null} +

+ ); +} + function Reading({ lines }: { lines: readonly string[] }) { return (
    diff --git a/src/lib/capital-hub-rules.test.ts b/src/lib/capital-hub-rules.test.ts index c077c5e5c..797386fab 100644 --- a/src/lib/capital-hub-rules.test.ts +++ b/src/lib/capital-hub-rules.test.ts @@ -26,6 +26,7 @@ import { selectDivergences, sevenDaySubline, columnIsWorthShowing, + sectionState, } from "./capital-hub-rules"; import { fmtUsdShort } from "./capital-hub-types"; @@ -343,3 +344,38 @@ describe("columnIsWorthShowing", () => { expect(printedValue(0, 9)).toBe(0); }); }); + +describe("sectionState", () => { + const B = (over: Partial<{ slug: string; live: boolean; failed: boolean }> = {}) => ({ + slug: "protocol-pf-ratio", + live: true, + failed: false, + ...over, + }); + + test("a section with rows is not missing, whatever the bench says", () => { + expect(sectionState([B({ failed: true, live: false })], "protocol-pf-ratio", 67)).toBeNull(); + expect(sectionState([B()], "protocol-pf-ratio", 1)).toBeNull(); + }); + + test("the three kinds of nothing are told apart, because they mean different things", () => { + // Transient: the load threw. The reading exists, this render did not get it. + expect(sectionState([B({ failed: true, live: false })], "protocol-pf-ratio", 0)).toBe("failed"); + // Not served here: a gated bench on a deployment that does not carry it. + expect(sectionState([B({ live: false })], "protocol-pf-ratio", 0)).toBe("unserved"); + // Served, ran, ranked nobody. The only one of the three that is a measurement. + expect(sectionState([B()], "protocol-pf-ratio", 0)).toBe("empty"); + }); + + test("a bench the deployment never listed stays silent, so a gated reading is not reported as missing", () => { + // Benches 280 and 281 are only in the list where they are served; without + // this the hub would print a "unavailable" note on prod for a reading it + // never promised. + expect(sectionState([B()], "usdc-corridor-flows", 0)).toBeNull(); + expect(sectionState([], "chain-fees-revenue", 0)).toBeNull(); + }); + + test("failed wins over live, so a bench that threw never reads as merely unserved", () => { + expect(sectionState([B({ live: true, failed: true })], "protocol-pf-ratio", 0)).toBe("failed"); + }); +}); diff --git a/src/lib/capital-hub-rules.ts b/src/lib/capital-hub-rules.ts index 61399c902..c39e7a771 100644 --- a/src/lib/capital-hub-rules.ts +++ b/src/lib/capital-hub-rules.ts @@ -311,6 +311,36 @@ export function selectDivergences(rows: T[], limi .slice(0, limit); } +/* --------------------------------------------- missing sections */ + +/** + * Which kind of nothing a whole section is, or null when it has rows. + * + * The four-cell rule one level up: an absent section must not read as a + * measured absence either. A bench entry carries `live` and `failed`, so the + * page can tell a transient load failure from a bench this deployment does not + * serve from a bench that ran and ranked nobody, and say which of the three it + * is instead of rendering nothing. + * + * Null when the bench is not in the list at all. That is the gated case + * (benches 280 and 281 are only listed where they are served), and a reading + * the deployment never promised is not a reading that is missing. + */ +export type SectionState = "failed" | "unserved" | "empty"; + +export function sectionState( + benches: readonly { slug: string; live: boolean; failed: boolean }[], + slug: string, + rowCount: number, +): SectionState | null { + if (rowCount > 0) return null; + const b = benches.find((x) => x.slug === slug); + if (!b) return null; + if (b.failed) return "failed"; + if (!b.live) return "unserved"; + return "empty"; +} + /* ------------------------------------------------------- 7d change */ /** Largest bucket-to-bucket ratio a 7d series may carry and still read as one continuous window. */ From f15c5be8c4c4c604399260ae50e8b0280e871c7d Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:53:19 +0200 Subject: [PATCH 007/187] the two blob proxies are ungated on purpose, and nothing said so (#2740) An audit found that /api/aggregate and /api/sitemap-data answer 200 on production carrying every dev-only bench, bench 208 included, with its full editorial fields and an seo_title naming a leader for a page that 404s. Eleven gated slugs in the aggregate, nine in the sitemap blob. The obvious fix is wrong and would cause an outage. Both libraries point every Vercel deployment at these routes ON THE PRODUCTION DOMAIN (aggregate-blob.ts:66, sitemap-blob.ts:4), because the alternative is a 12 s transatlantic fetch of a 7.5 MB blob from the Paris VPS. Staging is the only place the dev-only benches render, and it reads its data through production's proxy, so filtering on VERCEL_ENV in either route would strip all nine benches from staging. Verified: chain-bridged-tvl and perp-liq-rate both answer 200 on openchainbench-staging.vercel.app today. So the routes stay verbatim and now say why, at the top of each, addressed to whoever finds this next. The gate that matters is the loader (materialize/load.ts) and sitemap-builder.ts, and production's own surfaces are correct: sitemap.xml, llms.txt, RSS, /api/citable, /api/stat, MCP, the Markdown views and every page all drop bench 208, each checked live. What bounds the exposure, recorded with the warning: both routes are noindex via the /api/:path* X-Robots-Tag, neither is linked from any page, neither appears in llms.txt or the OpenAPI document, and the payload holds no secret. Scanned for keyed URL shapes (quiknode, alchemy, chainstack, getblock, api-key, token): none. While in that file, the leading docblock was wrong in three more ways. It described the three sets as one, so it gave 410 as the answer for a dev-only slug when the middleware only handles REMOVED_BENCH_SLUGS and a dev-only page 404s. It credited src/app/sitemap.ts, which does not exist since the sitemap became a Route Handler. And its list of protected surfaces was the list that omitted these two routes. It is now a map of which set does what, with the status code each produces and why a 404 is right for a URL that never published. Documentation only. typecheck, lint and the suite (368 tests) clean. --- src/app/api/aggregate/route.ts | 20 ++++++++++++ src/app/api/sitemap-data/route.ts | 11 +++++++ src/lib/removed-benches.ts | 52 +++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/app/api/aggregate/route.ts b/src/app/api/aggregate/route.ts index c8deae48e..6d182f43f 100644 --- a/src/app/api/aggregate/route.ts +++ b/src/app/api/aggregate/route.ts @@ -10,6 +10,26 @@ // aggregate-blob.ts can point AGGREGATE_BLOB_URL at this route instead // of kv.openchainbench.com to get near-zero latency from any Vercel // function in the same region. +// +// UNFILTERED BY DESIGN. This route serves the worker's blob verbatim, so on +// production it carries every dev-only bench (the DEV_ONLY_BENCH_SLUGS set in +// src/lib/removed-benches.ts) with its full editorial fields, including a +// seo_title that names a leader for a bench whose page 404s. An SEO audit on +// 2026-09-30 flagged that as a leak. Do not "fix" it by filtering on +// VERCEL_ENV here. +// +// The reason is aggregate-blob.ts:66: on ANY Vercel deployment, staging and +// previews included, the site fetches this route on the production domain. +// Staging is the only place the dev-only benches render, so filtering them out +// here would empty the staging pipeline of all nine of them. The gate belongs +// where it is, at the loader (src/lib/materialize/load.ts), which drops them +// per deployment; this route is transport underneath that gate, not a public +// surface. +// +// What bounds the exposure instead: the route is noindex (the /api/:path* +// X-Robots-Tag in next.config.ts), it is linked from no page and listed in +// neither llms.txt nor the OpenAPI document, and it carries no secret. Checked +// 2026-09-30: no keyed URL shape appears anywhere in the payload. export const runtime = "nodejs"; // force-dynamic + explicit Cache-Control: CDN caches via s-maxage without // generating ISR write entries. revalidateTag("bench-aggregate") from the diff --git a/src/app/api/sitemap-data/route.ts b/src/app/api/sitemap-data/route.ts index 04a6a6553..45964eb3f 100644 --- a/src/app/api/sitemap-data/route.ts +++ b/src/app/api/sitemap-data/route.ts @@ -1,3 +1,14 @@ +// Vercel-edge proxy for the worker's slim sitemap blob, the same shape of +// transport as /api/aggregate. +// +// UNFILTERED BY DESIGN, for the same reason and with the same warning: it +// serves the blob verbatim, so on production it lists every dev-only bench. +// sitemap-blob.ts:4 points every Vercel deployment at this route on the +// production domain, staging included, and staging is where those benches +// render. Filtering on VERCEL_ENV here would strip them from the staging +// sitemap. The gate that matters is in src/lib/sitemap-builder.ts, which drops +// them per deployment; sitemap.xml on production is correct and carries none +// of them. export const runtime = "nodejs"; export const revalidate = 3600; diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts index feaad9193..ae6b70d44 100644 --- a/src/lib/removed-benches.ts +++ b/src/lib/removed-benches.ts @@ -1,19 +1,45 @@ /** - * Bench slugs that must not exist on production. They render normally - * on dev / staging / preview (VERCEL_ENV !== "production") and are the - * staging pipeline: benches still being validated, or held back for a - * partnership announcement. + * Which bench slugs production does not serve, and what a hit on one gets. * - * Enforced in three places, all driven by this single set: - * 1. src/middleware.ts returns 410 Gone for direct URL hits on prod - * (SEO-correct signal for previously indexed URLs). - * 2. src/lib/materialize/load.ts drops the specs at loader level on - * prod, so the catalog index, category pages, /rpc hub, compare - * pairs, /api/citable, llms.txt and RSS never link or cite them. - * 3. src/app/sitemap.ts excludes the routes on prod. + * This file holds three sets, and they are NOT interchangeable. Each has its + * own docblock below; this is the map, because an earlier version of this + * comment described all of them as one and got the status code wrong. * - * Moving a bench to production = remove its slug here, bump the - * bench-set cache keys in src/lib/spec.ts, ship dev to main. + * REMOVED_BENCH_SLUGS retired for good, plus a few held for review. + * src/middleware.ts answers 410 Gone on prod, which is + * the SEO-correct signal for a URL that was indexed. + * RENAMED_BENCH_SLUGS split or renamed; a 308 to the successor, issued from + * next.config.ts redirects() so it costs no invocation. + * Checked before the 410 so a rename always wins. + * DEV_ONLY_BENCH_SLUGS the staging pipeline: still being validated, or held + * back on purpose. Dropped at the loader, so the page + * 404s rather than 410s. Nothing was ever published at + * those URLs, so there is no signal to preserve. + * + * The loader gate (src/lib/materialize/load.ts) is what keeps every rendered + * surface consistent: catalog index, category pages, /rpc hub, compare pairs, + * /api/citable, /api/stat, llms.txt, RSS, MCP and the Markdown views never + * link or cite a slug this deployment does not serve. The sitemap is dropped + * separately in src/lib/sitemap-builder.ts, through the isDevOnlyBench and + * isDevOnlyRoute helpers below rather than the sets directly, and is served by + * src/app/sitemap.xml/route.ts (there is no src/app/sitemap.ts; it became a + * Route Handler so it could set its own Cache-Control). + * + * Moving a bench to production = remove its slug from DEV_ONLY_BENCH_SLUGS, + * bump the bench-set cache keys in src/lib/spec.ts, ship dev to main. + * + * TWO PRODUCTION ENDPOINTS DELIBERATELY DO NOT ENFORCE ANY OF THIS, and an + * audit will find them again: /api/aggregate and /api/sitemap-data serve the + * worker's blobs verbatim, so on production both carry every dev-only slug + * with its editorial fields, including an seo_title naming a leader for a + * bench whose page 404s. Flagged 2026-09-30. They are transport, not a public + * surface: every Vercel deployment including staging fetches those two routes + * on the production domain (src/lib/aggregate-blob.ts, src/lib/sitemap-blob.ts), + * and staging is the only place these benches render, so filtering them there + * would empty the staging pipeline of all of them. Both routes carry that + * warning at the top. What bounds the exposure: noindex via the /api/:path* + * X-Robots-Tag, linked from no page, absent from llms.txt and the OpenAPI + * document, and no secret in the payload (no keyed URL shape, checked). */ /** * Bench slugs renamed / split, mapped to their canonical successor. From b9ae47f76f4ba6c14bc6c4220c58ba50a283f53f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:03:06 +0200 Subject: [PATCH 008/187] crm: a 24 h window next to the 7 d one, on every figure at once (#2742) Every windowed number on the dashboard was a fixed "last 7 days against the 7 before it". That is the right default for a site read by a few hundred people a week and the wrong one on the day something ships: a launch that triples today's traffic moves a 7-day average by a seventh, so the page meant to show the spike flattens it. The window is a parameter now. The header carries a 7 d / 24 h toggle, `?w=24h` switches the whole dashboard, and the comparison window is always the same length as the window itself, which is what the deltas on every card already claimed to be. The refresh runs the 20 windowed sections a second time for 24 h and stores them beside the first pass. The 8 long sections (the 28-day and 90-day series, the weekly cohorts, the kiosk list) mean the same thing whatever window the reader picked, so they are fetched once and a 24 h page reads them from the 7 d slice. That is 48 queries a pass against 28 before, 192 an hour at the 15 min cadence, 8 % of PostHog's organisation-wide 2,400; the local budget default goes from 300 to 500 so the manual Refresh button still fits beside it. Three things that had to follow the window and would otherwise have mislabelled a number: - every "vs previous 7 d" under a delta, which is why Metric and Kpi take a prevLabel instead of hardcoding a week - the manual refresh, which kept only the referer's pathname and so returned a 24 h reader to the 7 d view - every in-app link, so switching to 24 h and then clicking through does not silently drop back Two things deliberately do NOT follow it: the bench staleness thresholds on /health (Stale > 24 h, Expired > 7 d) describe the data, not the reader's window, and the daily history table reads history.jsonl, which records the 7 d headline numbers once a day whatever anyone is looking at. Both are commented where they sit. Tests cover that the window sets both intervals, that the fixed sections render identical SQL in either window, and that the windowed list is exactly the set whose SQL actually moves, so a section cannot be stored once and shown under two labels. --- crm/app/actions/page.tsx | 21 +++-- crm/app/api/refresh/route.ts | 12 ++- crm/app/audience/page.tsx | 46 ++++----- crm/app/health/page.tsx | 9 +- crm/app/page.tsx | 36 ++++--- crm/app/pages/page.tsx | 25 ++--- crm/components/metric.tsx | 6 +- crm/components/shell.tsx | 23 ++++- crm/components/ui.tsx | 17 +++- crm/lib/posthog.ts | 13 ++- crm/lib/snapshot.ts | 47 ++++++++-- crm/lib/traffic.ts | 175 +++++++++++++++++++++-------------- crm/lib/window.ts | 53 +++++++++++ crm/test/posthog.test.ts | 4 +- crm/test/traffic.test.ts | 74 +++++++++++---- 15 files changed, 395 insertions(+), 166 deletions(-) create mode 100644 crm/lib/window.ts diff --git a/crm/app/actions/page.tsx b/crm/app/actions/page.tsx index 7251b2fae..1bb4a602a 100644 --- a/crm/app/actions/page.tsx +++ b/crm/app/actions/page.tsx @@ -1,6 +1,7 @@ import { Shell } from "@/components/shell"; import { Delta, Empty, fmtInt, Kpi } from "@/components/ui"; -import { readSnapshot } from "@/lib/snapshot"; +import { parseWindow } from "@/lib/window"; +import { readSnapshot, trafficFor } from "@/lib/snapshot"; export const dynamic = "force-dynamic"; const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`; @@ -11,9 +12,10 @@ const ACTION_LABEL: Record = { search: "Searches", }; -export default async function ActionsPage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) { +export default async function ActionsPage({ searchParams }: { searchParams: Promise<{ refresh?: string; w?: string }> }) { const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); - const t = snap.traffic; + const w = parseWindow(sp.w); + const t = trafficFor(snap, w.key); const actions = t.actions ?? []; const byName = (n: string) => actions.find((a) => a.name === n); const outbound = (t.outbound ?? []).filter((o) => o.clicks > 0 || o.prevClicks > 0); @@ -21,16 +23,17 @@ export default async function ActionsPage({ searchParams }: { searchParams: Prom const searches = t.searches ?? []; return ( - +
    {(["outbound_click", "copy", "search"] as const).map((n) => { const a = byName(n); return ( ); @@ -43,7 +46,7 @@ export default async function ActionsPage({ searchParams }: { searchParams: Prom
    -

    Where visitors go, 7 d (outbound clicks by host)

    +

    {`Where visitors go, ${w.label} (outbound clicks by host)`}

    {outbound.length > 0 ? ( @@ -76,7 +79,7 @@ export default async function ActionsPage({ searchParams }: { searchParams: Prom )}
    -

    What gets copied, 7 d

    +

    {`What gets copied, ${w.label}`}

    {copies.length > 0 ? (
    @@ -115,7 +118,7 @@ export default async function ActionsPage({ searchParams }: { searchParams: Prom
    -

    Searches with no result, 7 d (content gaps)

    +

    {`Searches with no result, ${w.label} (content gaps)`}

    {(t.noResults ?? []).length > 0 ? (
    @@ -133,7 +136,7 @@ export default async function ActionsPage({ searchParams }: { searchParams: Prom
    -

    What people search for, 7 d (a result was picked)

    +

    {`What people search for, ${w.label} (a result was picked)`}

    {searches.length > 0 ? (
    diff --git a/crm/app/api/refresh/route.ts b/crm/app/api/refresh/route.ts index 3b4528667..dbdc297e2 100644 --- a/crm/app/api/refresh/route.ts +++ b/crm/app/api/refresh/route.ts @@ -1,13 +1,17 @@ import type { NextRequest } from "next/server"; import { seeOther } from "@/lib/auth"; import { MANUAL_COOLDOWN_MINUTES, readSnapshot, refreshSnapshot, snapshotAgeMinutes } from "@/lib/snapshot"; +import { DEFAULT_WINDOW, parseWindow } from "@/lib/window"; // Manual refresh, behind the session cookie (proxy.ts) and a cooldown: the // button cannot become a way to spend the PostHog budget. export async function POST(request: NextRequest) { const snap = await readSnapshot(); const age = snapshotAgeMinutes(snap); - // Back to the page the button was on; only the path of the referer is kept. + // Back to the page the button was on; only the path of the referer is + // kept, so the reporting window has to travel on the form action instead + // (Shell posts to /api/refresh?w=...). Without it, refreshing from a + // 24 h page silently returned the reader to the 7 d view. let back = "/"; try { const ref = new URL(request.headers.get("referer") ?? "", "http://x"); @@ -15,10 +19,12 @@ export async function POST(request: NextRequest) { } catch { // fall through to the overview } + const w = parseWindow(request.nextUrl.searchParams.get("w") ?? undefined); + const keep = w.key === DEFAULT_WINDOW ? "" : `&w=${w.key}`; if (age != null && age < MANUAL_COOLDOWN_MINUTES) { - return seeOther(request, `${back}?refresh=cooldown:${Math.ceil(MANUAL_COOLDOWN_MINUTES - age)}`); + return seeOther(request, `${back}?refresh=cooldown:${Math.ceil(MANUAL_COOLDOWN_MINUTES - age)}${keep}`); } const result = await refreshSnapshot("manual"); const flag = result.joined ? "joined" : result.stoppedBy ? "partial" : result.failed.length ? "errors" : "ok"; - return seeOther(request, `${back}?refresh=${flag}`); + return seeOther(request, `${back}?refresh=${flag}${keep}`); } diff --git a/crm/app/audience/page.tsx b/crm/app/audience/page.tsx index 2b943490d..c1e853646 100644 --- a/crm/app/audience/page.tsx +++ b/crm/app/audience/page.tsx @@ -2,7 +2,8 @@ import { FrequencyPanel, RetentionGrid } from "@/components/retention"; import { Shell } from "@/components/shell"; import { Bars, Delta, Empty, fmtInt, fmtPct, Kpi, Lines } from "@/components/ui"; import { SECTION_LABEL } from "@/lib/channels"; -import { readSnapshot } from "@/lib/snapshot"; +import { parseWindow, withWindow } from "@/lib/window"; +import { readSnapshot, trafficFor } from "@/lib/snapshot"; const fmtMs = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(2)} s` : `${Math.round(v)} ms`); const fmtSec = (v: number) => (v >= 60 ? `${Math.floor(v / 60)} min ${Math.round(v % 60)} s` : `${Math.round(v)} s`); @@ -64,9 +65,10 @@ function kpiSeries(t: { audienceDaily?: { day: string; visitors: number; newVisi return [...acc.entries()].map(([bucket, v]) => ({ bucket, value: v.num })); } -export default async function AudiencePage({ searchParams }: { searchParams: Promise<{ refresh?: string; range?: string; kpi?: string; g?: string }> }) { +export default async function AudiencePage({ searchParams }: { searchParams: Promise<{ refresh?: string; range?: string; kpi?: string; g?: string; w?: string }> }) { const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); - const t = snap.traffic; + const w = parseWindow(sp.w); + const t = trafficFor(snap, w.key); const a = t.audience; const active = a?.activeVisitors ?? 0; const firstDay = t.daily?.[0]?.day ?? null; @@ -75,8 +77,8 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro const detail = kpi ? kpiSeries(t, kpi, grain) : []; const detailShown = grain === "day" ? detail.slice(-28) : detail; const fmtVal = (v: number) => (kpi === "bounce" ? fmtPct(v) : fmtInt(v)); - const kpiHref = (k: Kpi) => `/audience?kpi=${k}&g=${grain}${sp.range ? `&range=${sp.range}` : ""}`; - const grainHref = (g: Grain) => `/audience?kpi=${kpi}&g=${g}${sp.range ? `&range=${sp.range}` : ""}`; + const kpiHref = (k: Kpi) => withWindow(`/audience?kpi=${k}&g=${grain}${sp.range ? `&range=${sp.range}` : ""}`, w); + const grainHref = (g: Grain) => withWindow(`/audience?kpi=${kpi}&g=${g}${sp.range ? `&range=${sp.range}` : ""}`, w); const range = sp.range === "all" ? "all" : "28"; const surfacesAll = t.surfaces ?? []; const surfaces = range === "all" ? surfacesAll : surfacesAll.slice(-28); @@ -91,15 +93,15 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro const referrers = (t.referrers ?? []).filter((r) => r.channel !== "direct" && r.channel !== "internal" && (r.visitors > 0 || r.prevVisitors > 0)).slice(0, 40); return ( - +
    {( [ - ["visitors", "Active visitors, 7 d", fmtInt(active), "click for the daily series"], - ["new", "New visitors, 7 d", fmtInt(a?.newVisitors), active > 0 && a ? `${fmtPct(a.newVisitors / active)} of active, first pageview in the window` : undefined], - ["returning", "Returning visitors, 7 d", fmtInt(a?.returningVisitors), active > 0 && a ? `${fmtPct(a.returningVisitors / active)} of active, seen on an earlier day` : "seen on an earlier day"], - ["sessions", "Sessions, 7 d", fmtInt(t.engagement?.sessions), undefined], - ["bounce", "Bounce rate, 7 d", fmtPct(t.engagement?.bounceRate), "single-pageview sessions"], + ["visitors", `Active visitors, ${w.label}`, fmtInt(active), "click for the daily series"], + ["new", `New visitors, ${w.label}`, fmtInt(a?.newVisitors), active > 0 && a ? `${fmtPct(a.newVisitors / active)} of active, first pageview in the window` : undefined], + ["returning", `Returning visitors, ${w.label}`, fmtInt(a?.returningVisitors), active > 0 && a ? `${fmtPct(a.returningVisitors / active)} of active, seen on an earlier day` : "seen on an earlier day"], + ["sessions", `Sessions, ${w.label}`, fmtInt(t.engagement?.sessions), undefined], + ["bounce", `Bounce rate, ${w.label}`, fmtPct(t.engagement?.bounceRate), "single-pageview sessions"], ] as const ).map(([k, label, value, sub]) => ( @@ -160,9 +162,9 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro Reads per surface, daily{firstDay ? `, since ${firstDay}` : ""} ({surfaces.length} d{logScale ? ", log scale" : ""})

    - 28 d + 28 d {" · "} - all ({surfacesAll.length} d, up to 90) + all ({surfacesAll.length} d, up to 90)

    {surfaces.length > 1 ? ( @@ -190,7 +192,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro
    -

    Endpoints read by agents, 7 d

    +

    {`Endpoints read by agents, ${w.label}`}

    {t.endpoints && t.endpoints.some((e) => e.reads > 0) ? (
    @@ -198,7 +200,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro - + @@ -230,7 +232,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro )}
    -

    Agent families, 7 d (server reads)

    +

    {`Agent families, ${w.label} (server reads)`}

    {families.length > 0 ? : }

    Family is a coarse user-agent bucket (gptbot, claudebot, perplexitybot, googlebot, curl, python, browser). Distinct agents are counted per user agent per day, never per IP. @@ -248,7 +250,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro

    -

    Countries, 7 d

    +

    {`Countries, ${w.label}`}

    {t.countries && t.countries.length > 0 ? ( ({ label: c.name, value: c.visitors, hint: `· ${fmtPct(c.share)}` }))} /> ) : ( @@ -256,13 +258,13 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro )}
    -

    Devices, 7 d

    +

    {`Devices, ${w.label}`}

    {t.devices && t.devices.length > 0 ? ( ({ label: d.name, value: d.visitors, hint: `· ${fmtPct(d.share)}` }))} /> ) : ( )} -

    UTM sources, 7 d

    +

    {`UTM sources, ${w.label}`}

    {t.utm && t.utm.length > 0 ? (
    Surface Path Readsvs prev 7 dvs prev {w.label} Agents Top families
    @@ -283,7 +285,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro
    -

    Core Web Vitals, 7 d (p75, by device)

    +

    {`Core Web Vitals, ${w.label} (p75, by device)`}

    {t.vitals && t.vitals.length > 0 ? (
    @@ -317,7 +319,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro

    -

    Time on page by section, 7 d (median of leaves)

    +

    {`Time on page by section, ${w.label} (median of leaves)`}

    {t.engaged && t.engaged.length > 0 ? (
    @@ -367,7 +369,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro
    -

    Referring domains, 7 d (direct and internal excluded)

    +

    {`Referring domains, ${w.label} (direct and internal excluded)`}

    {referrers.length > 0 ? (
    diff --git a/crm/app/health/page.tsx b/crm/app/health/page.tsx index 1a6e96f75..cdd2045e8 100644 --- a/crm/app/health/page.tsx +++ b/crm/app/health/page.tsx @@ -1,12 +1,14 @@ import { Shell, fmtAge } from "@/components/shell"; import { Empty, fmtInt, Kpi } from "@/components/ui"; import { readHistory, readSnapshot } from "@/lib/snapshot"; +import { parseWindow } from "@/lib/window"; export const dynamic = "force-dynamic"; const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`; -export default async function HealthPage({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) { +export default async function HealthPage({ searchParams }: { searchParams: Promise<{ refresh?: string; w?: string }> }) { const [snap, history, sp] = await Promise.all([readSnapshot(), readHistory(), searchParams]); + const w = parseWindow(sp.w); const b = snap.benches; const h = snap.harness; const d = snap.dune; @@ -14,7 +16,7 @@ export default async function HealthPage({ searchParams }: { searchParams: Promi const duneDaysLeft = d?.periodEnd ? Math.max(0, Math.ceil((Date.parse(d.periodEnd) - Date.now()) / 86_400_000)) : null; return ( - +
    @@ -136,6 +138,9 @@ export default async function HealthPage({ searchParams }: { searchParams: Promi
    + {/* The journal records the 7 d headline numbers once a day whatever + window the reader is on, so these columns say 7 d and do not + follow the toggle in the header. See HistoryLine in snapshot.ts. */}

    Daily history (one line per refresh day, kept on the volume)

    {history.length > 0 ? (
    diff --git a/crm/app/page.tsx b/crm/app/page.tsx index 0ecbc3e64..f1bb13bc8 100644 --- a/crm/app/page.tsx +++ b/crm/app/page.tsx @@ -3,16 +3,18 @@ import { Shell } from "@/components/shell"; import { Metric } from "@/components/metric"; import { Bars, Delta, Empty, fmtInt, fmtPct, Kpi, Spark } from "@/components/ui"; import { SECTION_LABEL } from "@/lib/channels"; -import { readSnapshot } from "@/lib/snapshot"; +import { parseWindow, withWindow } from "@/lib/window"; +import { readSnapshot, trafficFor } from "@/lib/snapshot"; import { channelTotals, sectionTotals } from "@/lib/traffic"; export const dynamic = "force-dynamic"; const CHANNEL_LABEL = { ai: "AI assistants", search: "Search", social: "Social", direct: "Direct", referral: "Other sites", internal: "Internal" } as const; -export default async function Overview({ searchParams }: { searchParams: Promise<{ refresh?: string }> }) { +export default async function Overview({ searchParams }: { searchParams: Promise<{ refresh?: string; w?: string }> }) { const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); - const t = snap.traffic; + const w = parseWindow(sp.w); + const t = trafficFor(snap, w.key); const totals = t.totals; const weekly = t.weekly ?? []; // Set below, after the partial-history helpers: the last two weeks that @@ -60,17 +62,19 @@ export default async function Overview({ searchParams }: { searchParams: Promise const h = snap.harness; return ( - +
    d.visitors)} unit="visitors" /> d.sessions)} unit="sessions" /> @@ -165,7 +173,7 @@ export default async function Overview({ searchParams }: { searchParams: Promise
    -

    Channels, 7 d (per-domain visitors summed)

    +

    {`Channels, ${w.label} (per-domain visitors summed)`}

    {channels.length > 0 ? (
    @@ -185,7 +193,7 @@ export default async function Overview({ searchParams }: { searchParams: Promise )}
    -

    AI assistants by domain, 7 d

    +

    {`AI assistants by domain, ${w.label}`}

    {aiDomains.length > 0 ? (
    @@ -205,14 +213,14 @@ export default async function Overview({ searchParams }: { searchParams: Promise )}
    -

    Sections, 7 d (page visits)

    +

    {`Sections, ${w.label} (page visits)`}

    {sections.length > 0 ? ( ({ label: SECTION_LABEL[s.section], value: s.visitors, hint: `· ${s.pages} pages` }))} /> ) : ( )}

    - Page visits sum per-page visitors; a visitor who saw two pages of a section counts twice. Pages has the per-page table. + Page visits sum per-page visitors; a visitor who saw two pages of a section counts twice. Pages has the per-page table.

    diff --git a/crm/app/pages/page.tsx b/crm/app/pages/page.tsx index f5618edba..8635499e2 100644 --- a/crm/app/pages/page.tsx +++ b/crm/app/pages/page.tsx @@ -1,27 +1,30 @@ import { Shell } from "@/components/shell"; import { Delta, Empty, fmtInt } from "@/components/ui"; import { SECTION_LABEL, type Section } from "@/lib/channels"; -import { readSnapshot } from "@/lib/snapshot"; +import { parseWindow, withWindow } from "@/lib/window"; +import { readSnapshot, trafficFor } from "@/lib/snapshot"; import { sectionTotals } from "@/lib/traffic"; export const dynamic = "force-dynamic"; const SITE = `https://${process.env.SITE_HOST ?? "openchainbench.com"}`; -export default async function PagesPage({ searchParams }: { searchParams: Promise<{ refresh?: string; section?: string }> }) { +export default async function PagesPage({ searchParams }: { searchParams: Promise<{ refresh?: string; section?: string; w?: string }> }) { const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); - const pages = snap.traffic.pages ?? []; + const w = parseWindow(sp.w); + const t = trafficFor(snap, w.key); + const pages = t.pages ?? []; const sections = sectionTotals(pages); const filter = (sp.section ?? "") as Section | ""; const shown = (filter ? pages.filter((p) => p.section === filter) : pages).slice(0, 100); const risers = pages.filter((p) => p.prevVisitors >= 3 || p.visitors >= 3).map((p) => ({ ...p, diff: p.visitors - p.prevVisitors })); const up = [...risers].sort((a, b) => b.diff - a.diff).slice(0, 10); const down = [...risers].sort((a, b) => a.diff - b.diff).filter((p) => p.diff < 0).slice(0, 10); - const entries = snap.traffic.entries ?? []; + const entries = t.entries ?? []; return ( - +
    -

    Sections, 7 d vs previous 7 d

    +

    {`Sections, ${w.label} vs ${w.prevLabel}`}

    {sections.length > 0 ? (
    @@ -38,7 +41,7 @@ export default async function PagesPage({ searchParams }: { searchParams: Promis {sections.map((s) => ( @@ -60,7 +63,7 @@ export default async function PagesPage({ searchParams }: { searchParams: Promis
    -

    Biggest gains, 7 d vs previous 7 d

    +

    {`Biggest gains, ${w.label} vs ${w.prevLabel}`}

    @@ -71,9 +74,9 @@ export default async function PagesPage({ searchParams }: { searchParams: Promis
    -

    Top pages, 7 d{filter ? ` · ${SECTION_LABEL[filter]}` : ""}

    +

    {`Top pages, ${w.label}`}{filter ? ` · ${SECTION_LABEL[filter]}` : ""}

    {filter && ( - + all sections )} @@ -115,7 +118,7 @@ export default async function PagesPage({ searchParams }: { searchParams: Promis
    -

    Entry pages, 7 d (first page of a session)

    +

    {`Entry pages, ${w.label} (first page of a session)`}

    {entries.length > 0 ? (
    - + {SECTION_LABEL[s.section]}
    diff --git a/crm/components/metric.tsx b/crm/components/metric.tsx index f39b6888a..525fa6a22 100644 --- a/crm/components/metric.tsx +++ b/crm/components/metric.tsx @@ -25,6 +25,7 @@ export function Metric({ delta, sub, unit, + prevLabel = "previous 7 d", }: { label: string; value: string; @@ -32,6 +33,9 @@ export function Metric({ delta?: { now: number; prev: number }; sub?: string; unit?: string; + /** What the delta compares against. Follows the reporting window, so a + * 24 h card never claims to be measured against the previous week. */ + prevLabel?: string; }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -60,7 +64,7 @@ export function Metric({

    {delta && ( <> - vs previous 7 d{sub ? " · " : ""} + vs {prevLabel}{sub ? " · " : ""} )} {sub} diff --git a/crm/components/shell.tsx b/crm/components/shell.tsx index 9c62c6a11..ebf882812 100644 --- a/crm/components/shell.tsx +++ b/crm/components/shell.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { MANUAL_COOLDOWN_MINUTES, REFRESH_MINUTES, snapshotAgeMinutes, type Snapshot } from "@/lib/snapshot"; +import { WINDOW_KEYS, WINDOWS, withWindow, type ReportWindow } from "@/lib/window"; const NAV = [ ["/", "Overview"], @@ -17,7 +18,7 @@ export function fmtAge(min: number | null): string { return `${Math.round(min / 1440)} d ago`; } -export function Shell({ current, snapshot, refreshFlag, children }: { current: string; snapshot: Snapshot; refreshFlag?: string; children: React.ReactNode }) { +export function Shell({ current, snapshot, refreshFlag, window: w, children }: { current: string; snapshot: Snapshot; refreshFlag?: string; window: ReportWindow; children: React.ReactNode }) { const age = snapshotAgeMinutes(snapshot); const errors = Object.entries(snapshot.status).filter(([, s]) => s.error); const canRefresh = age == null || age >= MANUAL_COOLDOWN_MINUTES; @@ -29,18 +30,34 @@ export function Shell({ current, snapshot, refreshFlag, children }: { current: s

    + {/* Every windowed figure on the dashboard follows this, and the + refresh stores both windows, so switching costs no query. */} +
    + {WINDOW_KEYS.map((k) => ( + + {WINDOWS[k].label} + + ))} +
    Snapshot {fmtAge(age)} {age != null && age < REFRESH_MINUTES ? ` · next in ${Math.max(1, Math.round(REFRESH_MINUTES - age))} min` : ` · refreshes every ${REFRESH_MINUTES} min`} {" "}· PostHog {snapshot.budget.used}/{snapshot.budget.limit} queries per hour -
    +
    - + diff --git a/crm/app/audience/page.tsx b/crm/app/audience/page.tsx index c1e853646..b6ec7ef12 100644 --- a/crm/app/audience/page.tsx +++ b/crm/app/audience/page.tsx @@ -377,7 +377,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro - + diff --git a/crm/app/pages/page.tsx b/crm/app/pages/page.tsx index 8635499e2..c259aacda 100644 --- a/crm/app/pages/page.tsx +++ b/crm/app/pages/page.tsx @@ -32,7 +32,7 @@ export default async function PagesPage({ searchParams }: { searchParams: Promis - + @@ -89,7 +89,7 @@ export default async function PagesPage({ searchParams }: { searchParams: Promis - + diff --git a/crm/lib/window.ts b/crm/lib/window.ts index aafa26c5c..f51dac9b1 100644 --- a/crm/lib/window.ts +++ b/crm/lib/window.ts @@ -28,11 +28,14 @@ export type ReportWindow = { label: string; /** Suffix for a delta: "vs previous 7 d". */ prevLabel: string; + /** Header for a delta column in a table. "w/w" under a 24 h window said + * week over week over a comparison that was a day. */ + deltaLabel: string; }; export const WINDOWS: Record = { - "7d": { key: "7d", days: 7, label: "7 d", prevLabel: "previous 7 d" }, - "24h": { key: "24h", days: 1, label: "24 h", prevLabel: "previous 24 h" }, + "7d": { key: "7d", days: 7, label: "7 d", prevLabel: "previous 7 d", deltaLabel: "w/w" }, + "24h": { key: "24h", days: 1, label: "24 h", prevLabel: "previous 24 h", deltaLabel: "d/d" }, }; export const DEFAULT_WINDOW: WindowKey = "7d"; From 092b81d434885a0d69ee5c8df0f6857fe7275080 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:03:48 +0200 Subject: [PATCH 010/187] the dashboard's agent numbers counted our own probes and missed every crawler (#2750) The audience page answered "who reads the site" from PostHog, and PostHog cannot answer it. Its SDK is client-side JavaScript, so nothing that skips JS is ever in it, and the server capture in src/lib/analytics-server.ts is wired to three routes, two of which sit behind a 60 s and a one hour edge cache and therefore emit one event per cache fill rather than per read. Every HTML page is ISR-cached, so a crawler fetching a bench page runs no code of ours at all. What the page showed on 2026-09-30: 317 server reads over its whole history, of which 19 from an identified AI agent, and the top three "agent families" were curl, browser and other, 205 of the 317 being our own curl probes and the Blackbox exporter. Read as adoption, that says agents ignore the site. Vercel, over the same three days, counted 75,429 requests from AI crawlers, 8,001 from search engine crawlers and 2,005 from AI assistants. The two sources are not close because they are not measuring the same thing. So this does not duplicate the collection. Vercel already logs every edge request with a classified bot, cache hits included, and lib/vercel-obs.ts queries it as a new snapshot section beside benches, harness and dune. The PostHog panels stay, now saying what they do and do not count. The API, /v2/observability/query, is the one `vercel metrics` uses and is not in the public REST docs, so the header of vercel-obs.ts keeps what was learned the hard way: the filter dialect is OData with single quotes, orderBy must name a rollup column, grouped dimensions come back snake_cased, granularity accepts only 5m/15m/1h/1d so whole-window totals are summed in code, and the per-path query 408s over 30 days and runs over 7. The AI total is published twice, with and without the single largest AI bot. One crawler, meta-externalagent, was 73,192 of the 75,429 and was looping over fifteen static pages rather than indexing, so a single number would read as adoption when it is one misbehaving bot. Retention starts when Observability was enabled on the team, 2026-09-28, so earlier days are real zeros; firstDayWithData is carried so the page can say so instead of implying the site was quiet. Without the three VERCEL_* variables the section says it is not configured and every other section is unaffected. --- crm/.env.example | 11 ++ crm/README.md | 11 +- crm/app/audience/page.tsx | 77 ++++++++++ crm/lib/snapshot.ts | 16 ++- crm/lib/vercel-obs.ts | 274 ++++++++++++++++++++++++++++++++++++ crm/scripts/agents-probe.ts | 41 ++++++ crm/test/vercel-obs.test.ts | 109 ++++++++++++++ 7 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 crm/lib/vercel-obs.ts create mode 100644 crm/scripts/agents-probe.ts create mode 100644 crm/test/vercel-obs.test.ts diff --git a/crm/.env.example b/crm/.env.example index a855f73fb..ec5f02cc8 100644 --- a/crm/.env.example +++ b/crm/.env.example @@ -16,3 +16,14 @@ REFRESH_MINUTES=15 SNAPSHOT_DIR=/data # Optional: Dune API key, to show credits left on the plan. DUNE_API_KEY= +# Vercel Observability, for the bot and agent section: every edge request with +# Vercel's own bot classification, which is the only source that sees a crawler +# reading an ISR-cached page. A token scoped to the OpenChainBench project is +# enough; the team and project ids are the ones in .vercel/project.json. +VERCEL_API_TOKEN= +VERCEL_TEAM_ID= +VERCEL_PROJECT_ID= +# Days of bot history to ask for, and the shorter window for the per-path +# query (which times out over the full one). +AGENT_WINDOW_DAYS= +AGENT_PATHS_WINDOW_DAYS= diff --git a/crm/README.md b/crm/README.md index ca08c36d9..1d894beeb 100644 --- a/crm/README.md +++ b/crm/README.md @@ -68,8 +68,15 @@ railway logs ``` Variables (Railway service settings): `CRM_PASSWORD`, `CRM_SESSION_SECRET`, `POSTHOG_PERSONAL_API_KEY`, -`POSTHOG_PROJECT_ID`, optionally `DUNE_API_KEY`, `POSTHOG_HOURLY_BUDGET`, -`REFRESH_MINUTES`. `SNAPSHOT_DIR=/data` and `PORT` are set on the service. +`POSTHOG_PROJECT_ID`, `VERCEL_API_TOKEN`, `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, optionally +`DUNE_API_KEY`, `POSTHOG_HOURLY_BUDGET`, `REFRESH_MINUTES`. `SNAPSHOT_DIR=/data` and `PORT` +are set on the service. + +The three `VERCEL_*` variables feed the bot and agent section, which reads Vercel +Observability rather than PostHog. PostHog cannot answer that question: its SDK is +client-side JavaScript and the server capture covers three routes, two of them cached, +so a crawler reading an ISR-cached page is invisible to it. Without the variables the +section says so and every other section is unaffected. See `lib/vercel-obs.ts`. ## Sessions diff --git a/crm/app/audience/page.tsx b/crm/app/audience/page.tsx index b6ec7ef12..98a25ee2d 100644 --- a/crm/app/audience/page.tsx +++ b/crm/app/audience/page.tsx @@ -69,6 +69,9 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro const [snap, sp] = await Promise.all([readSnapshot(), searchParams]); const w = parseWindow(sp.w); const t = trafficFor(snap, w.key); + // Bot traffic is not windowed: it comes from Vercel, not from the PostHog + // sections the ?w= switch re-queries, and carries its own window. + const ag = snap.agents; const a = t.audience; const active = a?.activeVisitors ?? 0; const firstDay = t.daily?.[0]?.day ?? null; @@ -190,6 +193,79 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro

    + {/* Bots and agents, measured by Vercel. Everything else on this page comes + from PostHog, which sees only what runs our JavaScript or reaches an + uncached route: an ISR-cached HTML page read by a crawler appears + nowhere in it. See lib/vercel-obs.ts. */} +
    +

    Bots and agents, {ag?.windowDays ?? 30} d (every edge request, from Vercel)

    + {ag && ag.totalRequests > 0 ? ( + <> +
    + + 0 ? `${fmtPct(ag.aiRequests / ag.totalRequests)} of all requests` : undefined} /> + + c.category.startsWith("search_engine")).reduce((n, c) => n + c.requests, 0))} + sub="googlebot, bingbot, and the rest" + /> +
    +
    +
    +

    By bot

    +
    Host Clicksw/w{w.deltaLabel} Visitors From
    Domain Channel Visitorsw/w{w.deltaLabel} Pageviews
    Section Page visitsw/w{w.deltaLabel} Pageviews Pages with a visit
    Path Section Visitorsw/w{w.deltaLabel} Pageviews
    + + + + + + + + + + {ag.bots.slice(0, 25).map((b) => ( + + + + + + + ))} + +
    BotCategoryRequestsShare of bot traffic
    {b.bot}{b.category || "unclassified"}{fmtInt(b.requests)} + {b.sharePct.toFixed(1)}% +
    +
    +
    +

    What AI crawlers read, {ag.aiPathsWindowDays} d

    + {ag.aiPaths.length > 0 ? ( + ({ label: r.path, value: r.requests }))} /> + ) : ( + + )} +

    + A crawler that reads the same handful of pages hundreds of times is looping, not indexing. Compare the top + paths with the bot table: if one bot carries most of the volume, the shape here is that bot's alone. +

    +
    + + + ) : ( + + )} +

    + Source: Vercel Observability, every edge request on production including cache hits, with Vercel's own bot + classification. This is the only section on the page that sees traffic which never runs our JavaScript, which is + all crawler traffic on ISR-cached pages. Retention starts when Observability was enabled on the team + {ag?.firstDayWithData ? ` (first day with data: ${ag.firstDayWithData})` : ""}; days before that read zero. +

    +
    +

    {`Endpoints read by agents, ${w.label}`}

    @@ -236,6 +312,7 @@ export default async function AudiencePage({ searchParams }: { searchParams: Pro {families.length > 0 ? : }

    Family is a coarse user-agent bucket (gptbot, claudebot, perplexitybot, googlebot, curl, python, browser). Distinct agents are counted per user agent per day, never per IP. + These two panels count reads of the three server surfaces only, and /api/stat and /api/citable behind their edge caches count cache fills. For who actually reads the site, use the Vercel section above: on 2026-09-30 these panels attributed most "agent" traffic to our own curl probes and Blackbox exporter, while Vercel counted 75,429 AI-crawler requests over the same three days.

    diff --git a/crm/lib/snapshot.ts b/crm/lib/snapshot.ts index f6f469aab..83a8aac88 100644 --- a/crm/lib/snapshot.ts +++ b/crm/lib/snapshot.ts @@ -14,6 +14,7 @@ import path from "node:path"; import { budget, BudgetExhausted, HOURLY_BUDGET, posthogConfigured, RateLimited } from "@/lib/posthog"; import { loadBenchHealth, loadDuneUsage, loadHarnessHealth, type BenchHealth, type DuneUsage, type HarnessHealth } from "@/lib/ocb"; import { isWindowed, loadTrafficSection, TRAFFIC_SECTIONS, type Traffic } from "@/lib/traffic"; +import { loadAgentTraffic, vercelObsConfigured, type AgentTraffic } from "@/lib/vercel-obs"; import { WINDOWS, type ReportWindow, type WindowKey } from "@/lib/window"; // 15 min by default: 48 queries per pass (28 sections over 7 d plus the 20 @@ -43,6 +44,8 @@ export type Snapshot = { benches: BenchHealth | null; harness: HarnessHealth | null; dune: DuneUsage | null; + /** Bot and agent traffic, from Vercel rather than PostHog; see lib/vercel-obs.ts. */ + agents: AgentTraffic | null; status: Record; budget: { used: number; limit: number }; }; @@ -58,7 +61,7 @@ export type HistoryLine = { targetsDown: number; }; -const EMPTY: Snapshot = { v: 1, refreshedAt: null, posthogConfigured: posthogConfigured(), traffic: {}, traffic24h: {}, benches: null, harness: null, dune: null, status: {}, budget: { used: 0, limit: HOURLY_BUDGET } }; +const EMPTY: Snapshot = { v: 1, refreshedAt: null, posthogConfigured: posthogConfigured(), traffic: {}, traffic24h: {}, benches: null, harness: null, dune: null, agents: null, status: {}, budget: { used: 0, limit: HOURLY_BUDGET } }; function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number { const n = Number.parseInt(raw ?? "", 10); @@ -186,6 +189,16 @@ async function doRefresh(reason: string): Promise { await step("dune", async () => { next.dune = await loadDuneUsage(); }); + // Vercel, not PostHog: every other section here is blind to anything that + // does not run our JavaScript or reach an uncached route of ours. + if (vercelObsConfigured()) { + delete next.status.agents; + await step("agents", async () => { + next.agents = await loadAgentTraffic(); + }); + } else { + next.status.agents = { at: null, error: "VERCEL_API_TOKEN, VERCEL_TEAM_ID or VERCEL_PROJECT_ID not set" }; + } if (posthogConfigured()) { // The "not configured" note from earlier refreshes must not outlive the fix. delete next.status.posthog; @@ -224,6 +237,7 @@ async function doRefresh(reason: string): Promise { "harness", "dune", "posthog", + "agents", ...TRAFFIC_SECTIONS.map((s) => statusKey(s, WINDOWS["7d"])), ...TRAFFIC_SECTIONS.filter(isWindowed).map((s) => statusKey(s, WINDOWS["24h"])), ]); diff --git a/crm/lib/vercel-obs.ts b/crm/lib/vercel-obs.ts new file mode 100644 index 000000000..608808088 --- /dev/null +++ b/crm/lib/vercel-obs.ts @@ -0,0 +1,274 @@ +/** + * Agent traffic, read from Vercel Observability rather than PostHog. + * + * PostHog cannot answer "who reads the site" and never will. Its client SDK + * is JavaScript, so nothing that skips JS is ever in it, and the server-side + * capture in src/lib/analytics-server.ts is wired to three routes only + * (/api/md, /api/stat, /api/citable), two of which are cached (60 s and one + * hour) and therefore emit one event per cache fill rather than per read. + * Every HTML page is ISR-cached, so a crawler fetching /benchmarks/ + * runs no code of ours and appears nowhere. + * + * Measured on 2026-09-30: PostHog showed 19 reads from identified AI agents + * over its whole history and attributed 65 % of "agent" traffic to our own + * curl probes and Blackbox exporter. Vercel, over the same three days, counted + * 75,429 requests from AI crawlers alone. The two are not close because they + * are not measuring the same thing. + * + * So this file does not duplicate the collection. Vercel already records every + * edge request with a classified bot, cache hits included; we query it. + * + * The API is `/v2/observability/query`, the one `vercel metrics` uses. It is + * not in Vercel's public REST documentation, so the pieces worth keeping: + * - the filter dialect is OData: `environment eq 'production'`, single + * quotes, not `environment:production` (that is the CLI's own surface + * syntax, rejected by the API); + * - `orderBy` must name a rollup column, `vercel_request_count_sum`, not + * the aggregation; + * - grouped dimensions come back snake_cased (`bot_name`, `bot_category`) + * even though `groupBy` takes them camelCased; + * - `vercel metrics schema vercel.request.count` lists every dimension. + * + * Retention starts when the Observability entitlement was enabled on the team + * (2026-09-28 here), so a 30-day window returns empty days before that. + */ +import { z } from "zod"; + +const API = "https://api.vercel.com/v2/observability/query"; +const TIMEOUT_MS = 45_000; + +const TOKEN = process.env.VERCEL_API_TOKEN ?? ""; +const TEAM = process.env.VERCEL_TEAM_ID ?? ""; +const PROJECT = process.env.VERCEL_PROJECT_ID ?? ""; + +/** Days of history each refresh asks for. Vercel bills nothing per query. */ +const WINDOW_DAYS = Number.parseInt(process.env.AGENT_WINDOW_DAYS ?? "", 10) || 30; + +/** + * The per-path query is the expensive one and 408s over the full window + * (`query_timeout` from Vercel, reproduced on 2026-09-30 at 31 days). Seven + * days serves in a few seconds and is the right question anyway: what the + * crawlers are reading now, not what they read a month ago. + */ +const PATHS_WINDOW_DAYS = Number.parseInt(process.env.AGENT_PATHS_WINDOW_DAYS ?? "", 10) || 7; + +export function vercelObsConfigured(): boolean { + return TOKEN.length > 0 && TEAM.length > 0 && PROJECT.length > 0; +} + +/** + * Vercel's own bot taxonomy, as returned in `botCategory`. Grouped here into + * the three questions the dashboard actually asks, so a new category Vercel + * adds shows up as itself rather than being silently folded into "other". + */ +const AI_CATEGORIES = new Set(["ai_crawler", "ai_assistant"]); +const SEARCH_CATEGORIES = new Set(["search_engine_crawler", "search_engine_optimization"]); + +export type AgentBotRow = { + bot: string; + category: string; + requests: number; + /** Share of all classified bot requests in the window. */ + sharePct: number; +}; +export type AgentCategoryRow = { category: string; requests: number }; +export type AgentDay = { day: string; ai: number; search: number; unclassified: number; other: number }; +export type AgentPathRow = { path: string; requests: number }; + +export type AgentTraffic = { + /** Window asked for, and the first day that actually carried data. */ + windowDays: number; + firstDayWithData: string | null; + /** Every request Vercel logged on production in the window, bots included. */ + totalRequests: number; + /** Requests whose bot category is an AI one. */ + aiRequests: number; + /** Requests from AI bots excluding the single largest one, which on this + * site is a crawler in a loop and swamps every other signal. */ + aiRequestsExcludingTop: number; + topAiBot: string | null; + categories: AgentCategoryRow[]; + bots: AgentBotRow[]; + daily: AgentDay[]; + /** What the AI crawlers actually read, over a shorter window than the rest. */ + aiPathsWindowDays: number; + aiPaths: AgentPathRow[]; +}; + +const rowSchema = z.object({ + timestamp: z.string().optional(), + vercel_request_count_sum: z.number(), + bot_name: z.string().optional(), + bot_category: z.string().optional(), + request_path: z.string().optional(), +}); +const responseSchema = z.object({ data: z.array(rowSchema) }); + +type Row = z.infer; + +function isoDay(offsetDays: number): string { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() + offsetDays); + return d.toISOString(); +} + +/** + * One grouped query. `filter` is OData; see the header note. + * + * Granularity is always one day: the API rejects anything but 5m, 15m, 1h and + * 1d (`Unsupported duration {"days":31}`), so a whole-window total cannot be + * asked for and is summed from the daily buckets here instead. `limit` is + * per bucket, not per result set, which is why it is generous. + */ +async function query(opts: { + groupBy: string[]; + days: number; + filter?: string; + limit?: number; +}): Promise { + const body = { + scope: { type: "project", ownerId: TEAM, projectIds: [PROJECT] }, + metric: "vercel.request.count", + selection: { aggregation: "count" }, + startTime: isoDay(-opts.days), + endTime: isoDay(1), + granularity: { days: 1 }, + groupBy: opts.groupBy, + filter: opts.filter ?? "environment eq 'production'", + limit: opts.limit ?? 40, + orderBy: "vercel_request_count_sum", + orderDirection: "desc", + }; + const res = await fetch(API + `?teamId=${encodeURIComponent(TEAM)}`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!res.ok) { + const detail = (await res.text()).slice(0, 300); + throw new Error(`vercel observability ${res.status}: ${detail}`); + } + return responseSchema.parse(await res.json()).data; +} + +export async function loadAgentTraffic(): Promise { + if (!vercelObsConfigured()) { + throw new Error("VERCEL_API_TOKEN, VERCEL_TEAM_ID or VERCEL_PROJECT_ID not set"); + } + + const [daily, bots, aiPaths] = await Promise.all([ + query({ groupBy: ["botCategory"], days: WINDOW_DAYS, limit: 20 }), + query({ groupBy: ["botName", "botCategory"], days: WINDOW_DAYS, limit: 40 }), + query({ + groupBy: ["requestPath"], + days: PATHS_WINDOW_DAYS, + filter: + "environment eq 'production' and (botCategory eq 'ai_crawler' or botCategory eq 'ai_assistant')", + limit: 15, + }), + ]); + return shapeAgentTraffic({ daily, bots, aiPaths, windowDays: WINDOW_DAYS, pathsWindowDays: PATHS_WINDOW_DAYS }); +} + +/** + * The shaping, kept pure and exported so it can be tested without the network. + * + * Two decisions live here rather than in the view. A category the taxonomy + * does not know (Vercel ships a dozen: browser_impersonation, http_client, + * client_anomaly, ...) is carried through as itself instead of being folded + * into "other", so a new one is visible the day it appears. And the AI total + * is published twice, with and without the single largest AI bot, because one + * crawler in a loop can be 84 % of it and a single number would read as + * adoption. + */ +export function shapeAgentTraffic(input: { + daily: Row[]; + bots: Row[]; + aiPaths: Row[]; + windowDays: number; + pathsWindowDays: number; +}): AgentTraffic { + const { daily, bots, aiPaths, windowDays: WINDOW_DAYS, pathsWindowDays: PATHS_WINDOW_DAYS } = input; + + // ── daily series, folded into the three questions ── + const byDay = new Map(); + for (const r of daily) { + const day = (r.timestamp ?? "").slice(0, 10); + if (!day) continue; + const cat = r.bot_category ?? ""; + const d = byDay.get(day) ?? { day, ai: 0, search: 0, unclassified: 0, other: 0 }; + if (AI_CATEGORIES.has(cat)) d.ai += r.vercel_request_count_sum; + else if (SEARCH_CATEGORIES.has(cat)) d.search += r.vercel_request_count_sum; + else if (cat === "") d.unclassified += r.vercel_request_count_sum; + else d.other += r.vercel_request_count_sum; + byDay.set(day, d); + } + const series = [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)); + + // A day is "with data" once anything at all was logged: the window reaches + // back past the day Observability was switched on, and those days are zero. + const firstDayWithData = + series.find((d) => d.ai + d.search + d.unclassified + d.other > 0)?.day ?? null; + + const categoryTotals = new Map(); + for (const r of daily) { + const cat = r.bot_category || "(unclassified)"; + categoryTotals.set(cat, (categoryTotals.get(cat) ?? 0) + r.vercel_request_count_sum); + } + + // ── bots ── + const botTotals = new Map(); + for (const r of bots) { + const name = r.bot_name ?? ""; + if (!name) continue; // the unclassified bucket is not a bot + const cur = botTotals.get(name) ?? { category: r.bot_category ?? "", requests: 0 }; + cur.requests += r.vercel_request_count_sum; + if (!cur.category && r.bot_category) cur.category = r.bot_category; + botTotals.set(name, cur); + } + const classifiedTotal = [...botTotals.values()].reduce((s, b) => s + b.requests, 0); + const botRows: AgentBotRow[] = [...botTotals.entries()] + .map(([bot, v]) => ({ + bot, + category: v.category, + requests: v.requests, + sharePct: classifiedTotal > 0 ? (v.requests / classifiedTotal) * 100 : 0, + })) + .sort((a, b) => b.requests - a.requests); + + const aiBots = botRows.filter((b) => AI_CATEGORIES.has(b.category)); + const aiRequests = aiBots.reduce((s, b) => s + b.requests, 0); + const topAiBot = aiBots[0]?.bot ?? null; + const aiRequestsExcludingTop = aiRequests - (aiBots[0]?.requests ?? 0); + + return { + windowDays: WINDOW_DAYS, + firstDayWithData, + totalRequests: daily.reduce((s, r) => s + r.vercel_request_count_sum, 0), + aiRequests, + aiRequestsExcludingTop, + topAiBot, + categories: [...categoryTotals.entries()] + .map(([category, requests]) => ({ category, requests })) + .sort((a, b) => b.requests - a.requests), + bots: botRows, + daily: series, + aiPathsWindowDays: PATHS_WINDOW_DAYS, + aiPaths: sumByPath(aiPaths), + }; +} + +/** Daily buckets summed into one row per path; see the granularity note. */ +function sumByPath(rows: Row[]): AgentPathRow[] { + const totals = new Map(); + for (const r of rows) { + if (r.vercel_request_count_sum === 0) continue; + const path = r.request_path || "/"; + totals.set(path, (totals.get(path) ?? 0) + r.vercel_request_count_sum); + } + return [...totals.entries()] + .map(([path, requests]) => ({ path, requests })) + .sort((a, b) => b.requests - a.requests); +} diff --git a/crm/scripts/agents-probe.ts b/crm/scripts/agents-probe.ts new file mode 100644 index 000000000..71c4fe7da --- /dev/null +++ b/crm/scripts/agents-probe.ts @@ -0,0 +1,41 @@ +/** + * Run the Vercel agent-traffic loader once and print what it returns. + * + * The queries are undocumented (see lib/vercel-obs.ts), so this exists to + * prove the loader against the live API without a full refresh. + * + * VERCEL_API_TOKEN=... VERCEL_TEAM_ID=... VERCEL_PROJECT_ID=... \ + * pnpm exec tsx scripts/agents-probe.ts + */ +import { loadAgentTraffic, vercelObsConfigured } from "../lib/vercel-obs"; + +async function main() { + if (!vercelObsConfigured()) { + console.error("not configured: set VERCEL_API_TOKEN, VERCEL_TEAM_ID, VERCEL_PROJECT_ID"); + process.exit(2); + } + const t = await loadAgentTraffic(); + console.log( + `window ${t.windowDays} d, first day with data ${t.firstDayWithData ?? "none"}\n` + + `total ${t.totalRequests.toLocaleString()} ` + + `ai ${t.aiRequests.toLocaleString()} ` + + `ai without ${t.topAiBot ?? "-"} ${t.aiRequestsExcludingTop.toLocaleString()}\n`, + ); + console.log("categories:"); + for (const c of t.categories) console.log(` ${c.requests.toString().padStart(9)} ${c.category}`); + console.log("\ntop bots:"); + for (const b of t.bots.slice(0, 15)) { + console.log(` ${b.requests.toString().padStart(9)} ${b.sharePct.toFixed(1).padStart(5)}% ${b.bot} (${b.category || "unclassified"})`); + } + console.log(`\ntop AI paths, ${t.aiPathsWindowDays} d:`); + for (const p of t.aiPaths.slice(0, 10)) console.log(` ${p.requests.toString().padStart(9)} ${p.path}`); + console.log("\ndaily (non-zero):"); + for (const d of t.daily.filter((d) => d.ai + d.search + d.unclassified + d.other > 0)) { + console.log(` ${d.day} ai ${d.ai.toString().padStart(7)} search ${d.search.toString().padStart(6)} other ${d.other.toString().padStart(6)} unclassified ${d.unclassified.toString().padStart(7)}`); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/crm/test/vercel-obs.test.ts b/crm/test/vercel-obs.test.ts new file mode 100644 index 000000000..1e4f19a69 --- /dev/null +++ b/crm/test/vercel-obs.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { shapeAgentTraffic } from "../lib/vercel-obs"; + +const day = (d: string) => `${d}T00:00:00.000Z`; + +describe("agent traffic shaping", () => { + test("publishes the AI total both with and without the largest bot", () => { + // The reason this figure is published twice: on 2026-09-30 a single + // crawler, meta-externalagent, was 73,192 of 75,429 AI requests, and it + // was looping over fifteen static pages rather than indexing. One number + // would have read as adoption. + const t = shapeAgentTraffic({ + daily: [], + bots: [ + { timestamp: day("2026-09-29"), vercel_request_count_sum: 73192, bot_name: "meta-externalagent", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 1432, bot_name: "oai-searchbot", bot_category: "ai_assistant" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 210, bot_name: "claudebot", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 3335, bot_name: "googlebot", bot_category: "search_engine_crawler" }, + ], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.aiRequests).toBe(73192 + 1432 + 210); + expect(t.topAiBot).toBe("meta-externalagent"); + expect(t.aiRequestsExcludingTop).toBe(1432 + 210); + // Googlebot is not an AI bot and must not be in the AI total. + expect(t.bots.find((b) => b.bot === "googlebot")?.category).toBe("search_engine_crawler"); + }); + + test("a bot's daily rows are summed, and the share is of bot traffic only", () => { + const t = shapeAgentTraffic({ + daily: [], + bots: [ + { timestamp: day("2026-09-28"), vercel_request_count_sum: 100, bot_name: "claudebot", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 300, bot_name: "claudebot", bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 100, bot_name: "googlebot", bot_category: "search_engine_crawler" }, + // The unclassified bucket comes back with an empty bot name; it is not a bot. + { timestamp: day("2026-09-29"), vercel_request_count_sum: 9999, bot_name: "", bot_category: "" }, + ], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.bots.map((b) => b.bot)).toEqual(["claudebot", "googlebot"]); + expect(t.bots[0].requests).toBe(400); + expect(t.bots[0].sharePct).toBeCloseTo(80, 5); + }); + + test("an unknown category is carried through as itself, not folded into other", () => { + // Vercel ships more categories than the two AI ones (browser_impersonation, + // http_client, client_anomaly...). Folding them would hide a new one. + const t = shapeAgentTraffic({ + daily: [ + { timestamp: day("2026-09-29"), vercel_request_count_sum: 500, bot_category: "browser_impersonation" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 300, bot_category: "ai_crawler" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 200, bot_category: "" }, + ], + bots: [], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.categories.map((c) => c.category)).toContain("browser_impersonation"); + expect(t.totalRequests).toBe(1000); + const d = t.daily[0]; + expect(d.ai).toBe(300); + expect(d.other).toBe(500); + expect(d.unclassified).toBe(200); + }); + + test("the first day with data skips the empty days before Observability was on", () => { + // The window reaches back past the day the entitlement was enabled, and + // those days come back as real zero rows rather than as absent rows. + const t = shapeAgentTraffic({ + daily: [ + { timestamp: day("2026-09-20"), vercel_request_count_sum: 0, bot_category: "" }, + { timestamp: day("2026-09-21"), vercel_request_count_sum: 0, bot_category: "ai_crawler" }, + { timestamp: day("2026-09-28"), vercel_request_count_sum: 11672, bot_category: "" }, + ], + bots: [], + aiPaths: [], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.firstDayWithData).toBe("2026-09-28"); + expect(t.daily.map((d) => d.day)).toEqual(["2026-09-20", "2026-09-21", "2026-09-28"]); + }); + + test("paths are summed across daily buckets and zero rows dropped", () => { + const t = shapeAgentTraffic({ + daily: [], + bots: [], + aiPaths: [ + { timestamp: day("2026-09-28"), vercel_request_count_sum: 1200, request_path: "/about" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 2271, request_path: "/about" }, + { timestamp: day("2026-09-29"), vercel_request_count_sum: 3538, request_path: "/contribute" }, + { timestamp: day("2026-09-27"), vercel_request_count_sum: 0, request_path: "/never-read" }, + ], + windowDays: 30, + pathsWindowDays: 7, + }); + expect(t.aiPaths).toEqual([ + { path: "/contribute", requests: 3538 }, + { path: "/about", requests: 3471 }, + ]); + expect(t.aiPathsWindowDays).toBe(7); + }); +}); From 07c3f8c3b2e2b2420a189a8d7bf1d30e21115c9f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:39:04 +0200 Subject: [PATCH 011/187] every outbound link says it came from here (#2753) Providers ask how much traffic we send them, and the honest answer was "look in your referrer report". For anyone arriving through a browser that strips the referrer, there was no answer at all. Outbound links now carry utm_source=openchainbench. Applied at render time, never stored. The registry and the specs keep clean URLs, so the JSON the API serves, the citations, the structured data and anything a reader copies stay canonical; only the href a human clicks is tagged. 40 links across 25 files: provider sites, docs, repos and X profiles, harness sources, venue and market pages, block explorer transactions, our own GitHub and X. Four kinds are deliberately left clean, and withUtm enforces each: - anything that is not http(s). mailto:, tel:, #anchors, relative paths, and the cursor:// and vscode: MCP deeplinks on /mcp. - our own site, subdomains included. Tagging an internal link starts a new campaign mid-visit and corrupts session attribution in PostHog. - a URL that already carries utm_source, so a hand-written campaign link keeps its own attribution. - identifiers that happen to be URLs: the Zenodo DOI and the Creative Commons deed are cited verbatim and appear in JSON-LD as well as in prose, where a tracking parameter is wrong in both places. RPC endpoint URLs are the case worth stating twice. They are printed so a reader can paste them into a config, so a utm_source in one would be copied into production. They never reach the helper, and a test asserts that the two modules that render them do not import it. The machine-readable surfaces are excluded too: /api/*, llms.txt, the sitemap and the JSON-LD builders hand URLs to agents and to other people's pages, where a tracking parameter ends up inside a citation or somebody else's HTML. Missing one of these is invisible: the link works and simply reports nothing. So the test greps the source rather than sampling behaviour. It fails on any literal or template-literal external href that is not wrapped, which is what caught the 28 the first pass had left, and it will fail on the next one somebody adds. --- src/app/about/page.tsx | 13 +-- src/app/alternatives/[slug]/page.tsx | 3 +- src/app/benchmarks/[slug]/page.tsx | 5 +- src/app/chains/[slug]/page.tsx | 3 +- src/app/data-api/page.tsx | 3 +- src/app/mcp/page.tsx | 7 +- src/app/methodology/page.tsx | 7 +- src/app/partners/page.tsx | 5 +- src/app/prediction-markets/page.tsx | 3 +- src/app/press/page.tsx | 3 +- src/app/products/[slug]/page.tsx | 5 +- src/app/reports/page.tsx | 5 +- src/app/rpc/page.tsx | 3 +- src/app/speedtest-rpc/page.tsx | 7 +- src/app/team/page.tsx | 5 +- src/app/trading-apps/page.tsx | 3 +- src/components/hl-top-users-table.tsx | 3 +- src/components/perp-venue-section.tsx | 5 +- src/components/pm-data-feed-section.tsx | 3 +- src/components/pm-top-markets-table.tsx | 3 +- src/components/pm-venue-section.tsx | 5 +- src/components/site-footer.tsx | 3 +- src/components/site-header.tsx | 7 +- src/components/site-sidebar.tsx | 5 +- src/components/terminal-fill-swaps.tsx | 7 +- src/lib/utm.test.ts | 124 ++++++++++++++++++++++++ src/lib/utm.ts | 56 +++++++++++ 27 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 src/lib/utm.test.ts create mode 100644 src/lib/utm.ts diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 48cf5bce0..357b1717c 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -4,6 +4,7 @@ import { SectionRule } from "@/components/section-rule"; import { aboutPageLd } from "@/lib/hub-jsonld"; import { safeJsonLd } from "@/lib/jsonld"; import { pageMetadata } from "@/lib/page-metadata"; +import { withUtm } from "@/lib/utm"; export const metadata: Metadata = pageMetadata({ path: "/about", @@ -59,11 +60,11 @@ export default function AboutPage() {

    • ·Open Prometheus data. Every number on the site is a literal quantile_over_time query. Anyone can hit our /api/citable or /api/stat/<slug> endpoint and re-derive the leaderboard with their own aggregation. The raw data is the source of truth.
    • -
    • ·Open harness source. Every harness is on GitHub under harnesses/. Clone, run docker compose up, your /metrics endpoint emits the same numbers ours does within 30 seconds.
    • -
    • ·Public methodology review. We invite external review and ship the fixes publicly. In June 2026 four issues were flagged on the stablecoin peg and oracle deviation benches; three were shipped in pull requests #349, #352 and #353 within twenty-four hours; the fourth was answered with citations to CME, Chainlink and CoinGecko convention.
    • +
    • ·Open harness source. Every harness is on GitHub under harnesses/. Clone, run docker compose up, your /metrics endpoint emits the same numbers ours does within 30 seconds.
    • +
    • ·Public methodology review. We invite external review and ship the fixes publicly. In June 2026 four issues were flagged on the stablecoin peg and oracle deviation benches; three were shipped in pull requests #349, #352 and #353 within twenty-four hours; the fourth was answered with citations to CME, Chainlink and CoinGecko convention.

    - No paid tier. No sold ranking slots. No provider sponsorship in exchange for inclusion. No token. If you spot any deviation from this policy, file a private security advisory and we will treat it as the integrity incident it would be. + No paid tier. No sold ranking slots. No provider sponsorship in exchange for inclusion. No token. If you spot any deviation from this policy, file a private security advisory and we will treat it as the integrity incident it would be.

    @@ -80,14 +81,14 @@ export default function AboutPage() {
    • ·Read the{" "}live benchmarks{" "}.
    • ·Reproduce any number. the{" "}methodology{" "}page tells you how.
    • -
    • ·Contribute a harness via{" "}GitHub. Pull requests for new providers and new benchmarks are reviewed openly.
    • -
    • ·Discuss ideas in{" "}GitHub Discussions{" "}or follow{" "}@OpenChainBench.
    • +
    • ·Contribute a harness via{" "}GitHub. Pull requests for new providers and new benchmarks are reviewed openly.
    • +
    • ·Discuss ideas in{" "}GitHub Discussions{" "}or follow{" "}@OpenChainBench.

    See a number you can't reproduce? File a{" "} - + data-quality issue or a provider correction . For press, partnerships or anything else, reach us at{" "} diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index a53096398..899d62c30 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -23,6 +23,7 @@ import { PERP_VENUE_META, benchRowsForVenue } from "@/lib/perp-venue-context"; import { fetchPerpCohort } from "@/lib/perp-stats"; import { PerpVenueBenchCards } from "@/components/perp-venue-bench-cards"; import { LedgerTable } from "@/components/ledger-table"; +import { withUtm } from "@/lib/utm"; export const revalidate = 3600; @@ -273,7 +274,7 @@ export default async function AlternativePage({ {alt.target_url && (

    About {alt.target_product}:{" "} - + {alt.target_url.replace(/^https?:\/\//, "")} diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 4b49d7da0..6241a506e 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -50,6 +50,7 @@ import { renderTemplate } from "@/lib/bench-template"; import { canonicalChainSlug } from "@/lib/chain-aliases"; import { PERSON_ID } from "@/lib/hub-jsonld"; import type { Benchmark } from "@/types/benchmark"; +import { withUtm } from "@/lib/utm"; // ISR with a 60 s revalidate window. The page is prerendered by // generateStaticParams below and served from the CDN until 60 s after @@ -1061,7 +1062,7 @@ export default async function BenchmarkPage({ {!isDraft && (

    Source code{" "} - + {benchmark.source.replace("https://github.com/", "github.com/")} @@ -1203,7 +1204,7 @@ function DraftNotice({ source }: { source: string }) { starts emitting metrics.

    Source code diff --git a/src/app/chains/[slug]/page.tsx b/src/app/chains/[slug]/page.tsx index 9e567f3fb..9484b6463 100644 --- a/src/app/chains/[slug]/page.tsx +++ b/src/app/chains/[slug]/page.tsx @@ -29,6 +29,7 @@ import { buildBreadcrumbJsonLd, safeJsonLd } from "@/lib/jsonld"; import { capDescription, capSnippet } from "@/lib/seo-text"; import { matchesChainSlug } from "@/lib/chain-aliases"; import type { Benchmark } from "@/types/benchmark"; +import { withUtm } from "@/lib/utm"; export const revalidate = 3600; export const maxDuration = 60; @@ -216,7 +217,7 @@ export default async function ChainPage({ {chain.website && ( @@ -220,7 +221,7 @@ export default async function McpPage() {

    Continue, Zed, Cline, Goose, custom agents on the{" "} - + MCP SDKs : all accept the same URL with the streamable-HTTP transport. SSE @@ -321,7 +322,7 @@ export default async function McpPage() { Rate limits, body caps, batch rejection and SSE disable are enforced at the route. Source is on{" "} diff --git a/src/app/methodology/page.tsx b/src/app/methodology/page.tsx index 665f47df1..43af0fa18 100644 --- a/src/app/methodology/page.tsx +++ b/src/app/methodology/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { methodologyPageLd } from "@/lib/hub-jsonld"; import { safeJsonLd } from "@/lib/jsonld"; import { pageMetadata } from "@/lib/page-metadata"; +import { withUtm } from "@/lib/utm"; export const metadata: import("next").Metadata = pageMetadata({ path: "/methodology", @@ -212,14 +213,14 @@ export default function MethodologyPage() { Found a number you can't reproduce? File a{" "} data-quality issue {" "} (the published figure looks wrong) or a{" "} provider correction {" "} @@ -238,7 +239,7 @@ export default function MethodologyPage() { or browse the source on{" "} GitHub diff --git a/src/app/partners/page.tsx b/src/app/partners/page.tsx index 07c826fdc..2eb625ba1 100644 --- a/src/app/partners/page.tsx +++ b/src/app/partners/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { pageMetadata } from "@/lib/page-metadata"; import { SITE } from "@/data/site"; import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; +import { withUtm } from "@/lib/utm"; /** * Partners and integrations page. Documents the public assets a provider, @@ -208,7 +209,7 @@ export default function PartnersPage() { or DM{" "} @@ -217,7 +218,7 @@ export default function PartnersPage() { on X. Source code lives on{" "} diff --git a/src/app/prediction-markets/page.tsx b/src/app/prediction-markets/page.tsx index 8983fe844..0976c02d2 100644 --- a/src/app/prediction-markets/page.tsx +++ b/src/app/prediction-markets/page.tsx @@ -14,6 +14,7 @@ import { CREATOR_PUBLISHER, DATASET_LICENSE, } from "@/lib/dataset-jsonld"; +import { withUtm } from "@/lib/utm"; /** * Hub landing page for the prediction markets cohort. SSR'd against @@ -440,7 +441,7 @@ export default async function PredictionMarketsHubPage() {

    Source:{" "}

    For interviews, custom benchmark requests or pre-publication embargoes. open an issue or a discussion on{" "} - GitHub. + GitHub.

    ); diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 242ed6647..39c898de8 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -57,6 +57,7 @@ import { getTerminalFills } from "@/lib/terminal-fills"; import { fetchDataApiSnapshot } from "@/lib/data-api-stats"; import { getTradingAppHistory } from "@/lib/trading-app-history"; import { fetchBridgeHub } from "@/lib/bridge-hub-stats"; +import { withUtm } from "@/lib/utm"; export const revalidate = 3600; @@ -1037,7 +1038,7 @@ export default async function ProviderPage({
  • {reg.url.replace(/^https?:\/\//, "").replace(/\/$/, "")} @@ -1048,7 +1049,7 @@ export default async function ProviderPage({
  • {reg.twitter} diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 30fd10402..243f59950 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -6,6 +6,7 @@ import { pageMetadata } from "@/lib/page-metadata"; import { reportsLandingLd } from "@/lib/hub-jsonld"; import { safeJsonLd } from "@/lib/jsonld"; import { SITE } from "@/data/site"; +import { withUtm } from "@/lib/utm"; export const metadata: Metadata = pageMetadata({ path: "/reports", @@ -53,7 +54,7 @@ export default function ReportsPage() { RSS Source: the open-source{" "} No install, no signup. Your URLs and API keys never leave this browser tab. Requests go straight from you to the provider.{" "} - + The probe engine is open source {" "} @@ -158,12 +159,12 @@ export default function SpeedtestRpcPage() {

    Everything on this page is open source:{" "} - + browser probe engine {" "} and the{" "}

    @@ -60,7 +61,7 @@ export default function TeamPage() { diff --git a/src/app/trading-apps/page.tsx b/src/app/trading-apps/page.tsx index 77ea7d61d..e0254ad28 100644 --- a/src/app/trading-apps/page.tsx +++ b/src/app/trading-apps/page.tsx @@ -15,6 +15,7 @@ import { import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; import { SITE } from "@/data/site"; +import { withUtm } from "@/lib/utm"; const DESCRIPTION = "Cross-chain daily volume, what each app charges on it, fill quality and app ratings for trading apps and Telegram bots (GMGN, Axiom, FOMO, Photon, Trojan), measured on closed UTC days."; @@ -423,7 +424,7 @@ export default async function TradingAppsHubPage() { on-chain swaps, app store ratings from the Apple iTunes lookup API. {pausedNote}All harnesses open source on{" "} , behind the "Perpetuals" pill. @@ -174,7 +175,7 @@ export async function PerpVenueSection({ {VENUE_TYPE_SENTENCE[venueType]}
    · {externalUrl && ( )} ))} Open an issue diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 72ed21a1c..fcd124319 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -8,6 +8,7 @@ import { SearchTrigger } from "@/components/search/search-trigger"; import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; import { headerNavItems, navGroups } from "@/components/site-nav-items"; import { ThemeToggle } from "@/components/theme-toggle"; +import { withUtm } from "@/lib/utm"; function XIcon({ size = 15 }: { size?: number }) { return ( @@ -116,7 +117,7 @@ export function SiteHeader({ hiddenRoutes = [] }: { hiddenRoutes?: readonly stri {/* Utilities - X + GitHub + theme, gap-spaced, no pipe. */}
  • setOpen(false)} diff --git a/src/components/site-sidebar.tsx b/src/components/site-sidebar.tsx index 3a3286981..0a47828b6 100644 --- a/src/components/site-sidebar.tsx +++ b/src/components/site-sidebar.tsx @@ -8,6 +8,7 @@ import { SearchTrigger } from "@/components/search/search-trigger"; import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; import { ThemeToggle } from "@/components/theme-toggle"; import { navGroups } from "@/components/site-nav-items"; +import { withUtm } from "@/lib/utm"; function XIcon({ size = 14 }: { size?: number }) { return ( @@ -271,7 +272,7 @@ export function SiteSidebar({ hiddenRoutes = [] }: { hiddenRoutes?: readonly str {pinned ? "Unpin the sidebar" : "Keep the sidebar open"} {txExplorer(s) ? ( { + test("tags a third-party destination", () => { + expect(withUtm("https://drpc.org")).toBe(`https://drpc.org/?utm_source=${UTM_SOURCE}`); + expect(withUtm("https://github.com/ChainBench/OpenChainBench")).toBe( + `https://github.com/ChainBench/OpenChainBench?utm_source=${UTM_SOURCE}`, + ); + }); + + test("keeps an existing query string and appends", () => { + expect(withUtm("https://github.com/x/y/issues/new?template=a.yml")).toBe( + `https://github.com/x/y/issues/new?template=a.yml&utm_source=${UTM_SOURCE}`, + ); + }); + + test("leaves our own site alone, subdomains included", () => { + // Tagging an internal link starts a new campaign mid-visit and breaks + // session attribution in PostHog. + for (const u of [ + "https://openchainbench.com/rpc", + "https://www.openchainbench.com/rpc", + "https://staging.openchainbench.com/rpc", + "https://kv.openchainbench.com/x", + ]) { + expect(withUtm(u)).toBe(u); + } + }); + + test("leaves anything that is not http(s) alone", () => { + for (const u of ["mailto:hello@openchainbench.com", "tel:+33", "#section", "/rpc", "../x"]) { + expect(withUtm(u)).toBe(u); + } + }); + + test("leaves identifiers alone: they are cited verbatim and appear in JSON-LD", () => { + for (const u of ["https://doi.org/10.5281/zenodo.20800312", "https://creativecommons.org/licenses/by/4.0/"]) { + expect(withUtm(u)).toBe(u); + } + }); + + test("does not overwrite a hand-written campaign", () => { + const u = "https://drpc.org/?utm_source=newsletter"; + expect(withUtm(u)).toBe(u); + }); + + test("empty and nullish are safe", () => { + expect(withUtm("")).toBe(""); + expect(withUtm(null)).toBe(""); + expect(withUtm(undefined)).toBe(""); + }); +}); + +/** + * The guarantee, not the sample. Grepping the source is the only way to + * know that no outbound link was missed, because a missed one renders + * perfectly and simply reports nothing. It also catches the next one + * somebody adds. + */ +describe("every outbound link is tagged", () => { + const files = execSync("git ls-files 'src/**/*.tsx' 'src/**/*.ts'", { encoding: "utf8" }) + .split("\n") + .filter(Boolean) + // Machine-readable surfaces are excluded on purpose: llms.txt, the + // JSON API and the badge snippet hand URLs to agents and to other + // people's pages, where a tracking parameter would end up inside a + // citation or someone else's HTML. + .filter((f) => !f.startsWith("src/app/api/")) + .filter((f) => !f.includes("llms") && !f.includes("sitemap") && !f.includes("jsonld")) + .filter((f) => !f.endsWith(".test.ts") && !f.endsWith("/utm.ts")); + + const offenders: string[] = []; + for (const f of files) { + const src = readFileSync(f, "utf8"); + const lines = src.split("\n"); + lines.forEach((line, i) => { + // A literal external href that is not already wrapped. + const m = line.match(/href="(https?:\/\/[^"]+)"/); + if (!m) return; + const url = m[1]; + if (url.includes("openchainbench.com")) return; + if (url.includes("doi.org") || url.includes("creativecommons.org")) return; + if (url.includes("utm_source")) return; + offenders.push(`${f}:${i + 1} ${url}`); + }); + } + + test("no literal https href escapes withUtm", () => { + expect(offenders).toEqual([]); + }); + + // The literal check above cannot see href={`https://x.com/${handle}`} + // or href={reg.url}. This one reads the same files for a template + // literal that opens on an external scheme and is not wrapped. + const templateOffenders: string[] = []; + for (const f of files) { + readFileSync(f, "utf8") + .split("\n") + .forEach((line, i) => { + const m = line.match(/href=\{`(https?:\/\/[^`]*)`\}/); + if (!m) return; + if (m[1].includes("openchainbench.com")) return; + templateOffenders.push(`${f}:${i + 1} ${m[1]}`); + }); + } + + test("no template-literal https href escapes withUtm", () => { + expect(templateOffenders).toEqual([]); + }); + + // The endpoint URLs are the one case that must stay clean: a reader + // copies them into a config file. + test("RPC endpoint URLs are never tagged", () => { + expect(withUtm("https://eth.drpc.org")).not.toBe("https://eth.drpc.org"); + // the helper would tag it, which is why no endpoint renderer calls it: + const endpointRenderers = files.filter((f) => /rpc-directory|speedtest-rpc-client/.test(f)); + for (const f of endpointRenderers) { + expect(readFileSync(f, "utf8")).not.toContain("withUtm"); + } + }); +}); diff --git a/src/lib/utm.ts b/src/lib/utm.ts new file mode 100644 index 000000000..085adfc14 --- /dev/null +++ b/src/lib/utm.ts @@ -0,0 +1,56 @@ +import { SITE } from "@/data/site"; + +/** + * Tags an outbound link so the destination can see the traffic came from + * here. Providers ask us how much we send them; without this the answer + * is buried in their referrer reports, and for the ones fronted by a + * privacy-preserving browser there is no referrer at all. + * + * Applied at RENDER time, never stored. The registry and the specs keep + * clean URLs, so the JSON the API serves, the citations, the structured + * data and anything a reader copies stay canonical. Only the href a human + * clicks carries the tag. + * + * What it deliberately leaves alone: + * + * - Anything that is not http(s): mailto:, tel:, #anchors, relative + * paths. A query string on those is either meaningless or breaks them. + * - Our own site, including subdomains. An internal link does not need + * to tell us where it came from, and tagging one would corrupt the + * session attribution in PostHog by starting a new campaign mid-visit. + * - A URL that already carries utm_source, so a hand-written campaign + * link keeps its own attribution. + * - Identifiers that happen to be URLs: a DOI and a Creative Commons + * license deed are cited verbatim, appear in JSON-LD as well as in + * prose, and a tracking parameter on them is wrong in both places. + * + * It is NOT applied to RPC endpoint URLs. Those are shown so a reader can + * paste them into a config; a utm_source in one would be copied straight + * into production. They never pass through here, and the test in + * utm.test.ts pins that. + */ +const SITE_HOST = new URL(SITE.url).hostname.replace(/^www\./, ""); + +/** Hosts whose URLs are identifiers, not destinations. */ +const IDENTIFIER_HOSTS = new Set(["doi.org", "dx.doi.org", "creativecommons.org"]); + +export const UTM_SOURCE = "openchainbench"; + +export function withUtm(href: string | null | undefined): string { + if (!href) return ""; + let u: URL; + try { + u = new URL(href); + } catch { + return href; // relative path, #anchor, or malformed: leave it alone + } + if (u.protocol !== "https:" && u.protocol !== "http:") return href; + + const host = u.hostname.replace(/^www\./, ""); + if (host === SITE_HOST || host.endsWith(`.${SITE_HOST}`)) return href; + if (IDENTIFIER_HOSTS.has(host)) return href; + if (u.searchParams.has("utm_source")) return href; + + u.searchParams.set("utm_source", UTM_SOURCE); + return u.toString(); +} From b245b67914b0e0d3ac02091da0d5892cb67669ef Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:08:19 +0200 Subject: [PATCH 012/187] rpc-cost: a dedicated node priced every chain tab, including ones it does not serve (#2741) * the unfiltered view selected every workload at once, so the page showed almost nothing injectLabels treats a selector pinned to `="all"` as the pooled form and replaces it when a reader picks a tab; a selector with no such matcher spans every combination instead. The queries here carried neither, so the default view asked for one provider across five profiles, three volumes and seven plan tiers at once, and the providers whose cheapest plan differs per slice fell out of the table entirely. Pooling a bill across workloads would be meaningless, averaging a $24 plan with a $10,500 one, so the harness publishes the headline slice under `kind="all"` / `bucket="all"` instead: a dapp method mix at a hundred million requests a month. The dimension labels name that slice rather than claiming to be an average. * a provider that cannot serve the workload was reading as free Seven rows showed $0 on the rpc-cost leaderboard: Infura, whose daily allowance hard-stops well below the workload, and six providers with no product on the chain being priced. $0 says "free" where the truth is "unavailable", which is the one mistake this bench exists to avoid. The spec now carries a `ranked` query wired to rpc_cost_eligible, so those rows move to the unranked block with their reason instead of posting a price nobody offers. The selector labels were named after the dimension KEY, so the page offered "Venue", "Trade size" and "Kind" for what are really plan tiers, monthly request volume and workload profile. `dimension_labels` lets a spec name its own axes; every bench that sets nothing keeps the label it has today. Default view moves to 10M requests a month, the volume most readers arrive with, and the pinned `venue="all"` now carries the cheapest PAID plan: at 10M several free tiers are eligible and would otherwise take every top row, hiding the comparison. The Free tier tab answers that question on its own axis. Also: product-page entries for the seven providers that had none, and logos for four of them. BlockPI, Coinbase CDP and Instanodes publish only an .ico or a JS-rendered page, so they keep the initials fallback. * an adversarial audit found the numbers sound and the claims around them not Eight agents re-checked the bench against primary sources, the code, and a third independent recomputation. The money holds: 1,155 of 1,194 cells agreed first try, no cell is non-monotonic, and the winner table reproduces dollar for dollar. What did not hold was the prose, and six model bugs. The worst bug: Chainstack's archive rule was recorded and never applied. `block_age` was treated as a no-op on the theory it was already in the weights; it was not, so the indexer profile billed 1 Request Unit per request instead of 2 and the page published $3,990 for a bill of $8,990. An unknown archive rule now fails closed instead of pricing archive at par. Also fixed: the throughput gate read the billing unit when Alchemy meters the rate limit in a different one (25x apart on debug_traceTransaction), so the check now uses the throughput table and reproduces Alchemy's own 61,728 and 234,568 CU/s exactly; the dedicated cohort had no weights and rendered nine empty rows; the `cohort` label did not match the `tier` dimension key, so that selector matched nothing; reference rows were filtered out by the paid-only default; and nothing was ever Reset, so a provider that stopped being eligible kept exporting its last price. Nineteen published claims were false or unsupportable and are corrected or withdrawn, the largest being "71x" (7.6x), "five winners" (six), "the lowest per-request price in the market" (three providers beat it), "the page names the reason" (it does not, yet), and four separate promises that freshness was being checked when no baseline hash had ever been recorded. Google Blockchain Node Engine was published as live capacity; Google shuts it down on 15 December 2026. And a product page described InstantNodes, which is dead, under the slug of Instanodes, which is trading. * keep the promises instead of withdrawing them The audit found four things the page claimed and the code did not do. The last commit made the prose honest by dropping the claims; this one does the work instead, for three of the four. Drift detection is armed. 21 artifacts now carry a baseline hash recorded from their live body, so a provider editing its pricing surfaces as drift rather than as a stale figure that still reads as current. The remaining artifacts stay on manual re-reading on purpose: Chainstack's pricing JSON regenerates every fifteen minutes and the big marketing pages churn on any unrelated edit, so hashing them would alarm every cycle until everyone learned to ignore the alarm. Age is published either way, so a figure nobody has re-read is visible as one. An artifact we deliberately never fetch no longer reports ok=0. That gauge read as "the provider's page is down" for 38 artifacts nobody had asked about, which is a different and untrue claim; it is now simply absent. The free-tier allowance, the effective cost per million, the billing units per request and the dedicated cohort's break-even volume were all being emitted and rendered nowhere. Each now has a panel. The ledger's p50/p90/p99 columns are relabelled: the bill is a constant read from a committed catalogue, so three percentiles of it are the same number three times, and the success-rate columns are off because this bench sends no RPC traffic and had no business showing 100%. Still not done: the reason a provider is absent from a cell reaches the harness log and not the page. The prose says so rather than implying otherwise. * a volume tab showed another volume's numbers, and a free provider was pruned as missing Two bugs in shared code, both found by auditing what the page rendered rather than what the spec said. The variant fetch never sent the bucket filter, and the all-selection test that short-circuits it never considered the bucket either. So picking only a volume fetched nothing, any other fetch came back with the default volume's figures, and the result was cached under a key that DOES include the volume. Every bucket tab rendered the default one. This also broke bench 268's trade-size tabs, which is the dimension the key was added for. The ledger and the summary strip prune all-zero rows because on a latency bench a provider emitting 0 ms is dead, not instant. On a cost bench $0 is the answer: dRPC and OnFinality genuinely serve a ten-million-request month for nothing, and pruning them made the page name a paid provider as the cheapest option. Rather than change that rule for 240 benches, a spec can now declare `zero_is_a_value`, defaulting to false so nothing else moves. * a dedicated node priced every chain tab, including the ones it does not serve The audit caught two blockers, both introduced this afternoon while fixing something else. Making the dedicated cohort unmetered so it would stop rendering nine empty rows also skipped unitsPerRequest, which held the only chain check. So AWS, whose three plans are Ethereum nodes, led the Solana tab at $97.82; Allnodes showed $320 for its Ethereum plan where its Solana plan is about $1,920; and Helius's Solana node carried an Ethereum bill. Every plan now declares the chains it serves, taken from its own published name rather than inferred from its id, and the catalogue refuses to load dedicated capacity that declares none. That hole cannot reopen quietly. The free-tier panel ranked dRPC first on the trace tab at 10.5M requests while its own free plan is archive: false, trace: false and the ledger directly below refused it. The panel now applies the same eligibility gates as the table, so an allowance a plan cannot spend on a workload is not published as one. Fixing the first surfaced a third: the break-even denominator was the cheapest simple-read rate, and simple-read is an Ethereum profile, so every Solana node was dropped for want of a denominator. The floor is now computed per chain from that chain's own baseline workload, and a break-even is labelled with the chain it was actually computed against. * the sentences describing the numbers went stale every time the numbers moved Four published claims were hand-written about values the model computes, so correcting Chainstack's archive rule, the throughput meter and the dedicated cohort's chain gate silently falsified them. The audit caught it; the text had been corrected once already and had rotted again by the time the model settled. Recomputed from the harness's own 1,485 series: five providers win a cell outright rather than six, and two more share a cell on a tie; the trace profile at a hundred million spans 6.7x from NOWNodes at $567.75, not 7.6x from Chainstack at $499; Alchemy and QuickNode run 1.7x to 10.6x above the cell leader, not 2.5x; and Chainstack, which the copy still named as a trace winner, wins no cell outright. The durable half of the fix is a per-bench plugin in the audit skill that recomputes all four from Prometheus and fails when the prose disagrees, plus a check that understands `dimensions.tier` partitions the cohort so the generic row count stops reporting 20 of 31 as a fault. * the quotable sentence crowned a provider on a slice it never named The SEO audit found the page handing answer engines the exact claim its own intro says cannot be made. "BlockPI posts the lowest monthly cost at $29.50 across 15 ranked providers" appears on the TL;DR, the infobox, the results heading, the table header, the StatisticalReport JSON-LD, /api/stat, /api/citable and llms.txt. It holds for the dapp workload, on Ethereum, at ten million requests a month, on the cheapest paid plan, and for none of the other fourteen cells. None of those four conditions appeared anywhere in the served HTML, and /api/stat returned filters: null. A bench that declares dimensions answers a different question per tab, so the headline now names the slice it was measured on. A dimension whose first value reads "All ..." is a genuine aggregate and is skipped, which leaves every chain- and region-dimensioned bench untouched; tier is skipped too, since it partitions the provider list rather than the measurement and the cohort size is already in the sentence. Also: the meta description rendered at 65 characters because the spec's was 207, over the cap, and the render kept only its first sentence. Rewritten to 139, inside the window. --- benchmarks/rpc-cost.yml | 12 ++++---- harnesses/rpc-cost/cmd/script/catalogue.go | 31 +++++++++++++++++++ harnesses/rpc-cost/cmd/script/main.go | 33 ++++++++++++-------- harnesses/rpc-cost/cmd/script/model.go | 23 ++++++++++++++ harnesses/rpc-cost/cmd/script/profiles.go | 15 +++++++++ harnesses/rpc-cost/pricing/catalogue.yml | 36 ++++++++++++++++++++++ src/lib/citation.test.ts | 34 ++++++++++++++++++++ src/lib/citation.ts | 33 +++++++++++++++++++- 8 files changed, 198 insertions(+), 19 deletions(-) diff --git a/benchmarks/rpc-cost.yml b/benchmarks/rpc-cost.yml index 5a68b8bac..7fa1ca093 100644 --- a/benchmarks/rpc-cost.yml +++ b/benchmarks/rpc-cost.yml @@ -4,7 +4,7 @@ slug: rpc-cost number: "282" title: Cheapest RPC provider, priced per workload across 18 ranked providers seo_title: "Cheapest RPC provider 2026: real cost per workload" -seo_description: "What 18 ranked RPC providers actually bill for the same workload. Alchemy, QuickNode, Chainstack, Infura, Helius, BlockPI, dRPC and more, priced across 5 workload profiles, 3 volumes and every plan tier." +seo_description: "What 18 RPC providers bill for the same workload. Alchemy, QuickNode, Chainstack, BlockPI and dRPC priced across 5 workloads and 3 volumes." subtitle: The monthly bill each RPC provider would send for the same workload, normalised across Compute Units, Request Units, credits and flat requests, at three volumes and every plan tier. category: RPCs @@ -95,11 +95,11 @@ methodology: - "What is still not measured. These are published prices, not invoices. Alchemy and QuickNode carry no credit, compute-unit or quota header on any response, so their billed units cannot be confirmed without dashboard credentials we do not hold. Where a provider's real charge is non-deterministic by design, dRPC routes to third-party operators that can rewrite the price downward and exposes no per-response cost header, the published rate is reported as an upper bound and labelled as one." findings: - - "There is no cheapest RPC provider. Six of them win at least one of the fifteen workload-by-volume cells, and none wins more than four. The widest spread inside a single cell is 36.7x, and even the trace profile at a hundred million requests spans 7.6x, from Chainstack at $499 to GetBlock at $3,779." - - "Two of the best-known providers are among the most expensive. Alchemy and QuickNode win no cell at any volume on any workload, running between 2.5x and 10.6x the leader of each cell, Alchemy at $10,500 a month for a billion simple reads against BlockPI's $2,833." + - "There is no cheapest RPC provider. Five win a workload-by-volume cell outright, none more than four of the fifteen, and two more share a cell on a tie. The widest spread inside one cell is 36.7x, on a billion Solana bot requests, and even the trace profile at a hundred million spans 6.7x, from NOWNodes at $567.75 to GetBlock at $3,779." + - "Two of the best-known providers are among the most expensive. Alchemy and QuickNode win no cell at any volume on any workload, running between 1.7x and 10.6x the leader of each cell, Alchemy at $10,500 a month for a billion simple reads against BlockPI's $2,833." - "Price is not always the binding constraint. Alchemy cannot serve the indexer or trace profile at a billion requests a month on any published self-serve plan: those need 61,728 and 234,568 Throughput Compute Units per second against a self-serve ceiling of 30,000, enforced per account rather than per app." - "Alchemy meters volume and rate limit in different units, and the figures above use its throughput column, not its billing one. Infura, separately, cannot serve a hundred million requests a month at any price, because its allowance is daily and hard-stops at 402." - - "Only some of the cell winners rest on fully published, unambiguous numbers. Chainstack and BlockPI win on complete figures. OnFinality's default method weight is assumed on chains it says carry undisclosed higher weights, NOWNodes publishes no archive or trace availability at all while winning the largest trace cell on price, and Syndica's pricing survives only inside a calculator that ends in a lead-capture form." + - "Only some of the cell winners rest on fully published, unambiguous numbers. BlockPI and Chainstack publish complete figures. OnFinality's default method weight is assumed on chains it says carry undisclosed higher weights, NOWNodes publishes no archive or trace availability at all while winning the largest trace cell on price, and Syndica's pricing survives only inside a calculator that ends in a lead-capture form." - "The cheapest defensible answer for a billion trace requests a month is therefore dRPC at $6,000, which is 5.7% above NOWNodes and the cheapest option that actually publishes that it serves debug and trace." - "Free tiers differ by 160x once the allowance is converted into requests. OnFinality's buys 12.0 million simple reads a month and GetBlock's buys 75,000. Several free tiers are throughput-capped rather than volume-capped, and GetBlock's daily allowance drains in 125 seconds at its own advertised rate." - "Buying the small plan is often the most expensive choice. At a hundred million dapp requests the entry band costs $524.50 while the growth band costs $249 and the business band $499, because the entry allowance runs out and the overage rate does not fall." @@ -110,9 +110,9 @@ findings: faq: - q: "Which RPC provider is cheapest?" - a: "It depends on the workload, by multiples rather than percent. Across five workload profiles and three volumes, six providers win at least one cell: BlockPI and OnFinality four each, Chainstack three, Syndica two, and NOWNodes and Helius one apiece, with several ties at $49. Pick the profile that matches your method mix rather than reading a single number." + a: "It depends on the workload, by multiples rather than percent. Across five workload profiles and three volumes, five providers win a cell outright: BlockPI and OnFinality four each, NOWNodes and Syndica two each, and dRPC one. Two further cells are ties at $49, shared by Chainstack, Helius and OnFinality. Pick the profile that matches your method mix rather than reading a single number." - q: "Why do Alchemy and QuickNode never win?" - a: "Because they are three to four times more expensive than the leaders for the same work. Alchemy bills $10,500 a month for a billion simple reads where BlockPI bills $2,833, and across the fifteen cells the two run between 2.5x and 10.6x the cell leader. Both are priced as premium products and both publish their rates plainly; the bench simply applies them." + a: "Because they are three to four times more expensive than the leaders for the same work. Alchemy bills $10,500 a month for a billion simple reads where BlockPI bills $2,833, and across the fifteen cells the two run between 1.7x and 10.6x the cell leader. Both are priced as premium products and both publish their rates plainly; the bench simply applies them." - q: "How can you compare a Compute Unit to a Request Unit?" a: "By applying each provider's own method table to the same method mix, which gives weighted units per request. That figure is published per provider and per profile, so the conversion is inspectable rather than asserted. It is also why the ranking changes with the workload: the unit conversions are not constant across methods." - q: "Are these prices from real invoices?" diff --git a/harnesses/rpc-cost/cmd/script/catalogue.go b/harnesses/rpc-cost/cmd/script/catalogue.go index 2bd575450..cf2f57b31 100644 --- a/harnesses/rpc-cost/cmd/script/catalogue.go +++ b/harnesses/rpc-cost/cmd/script/catalogue.go @@ -158,6 +158,13 @@ type Plan struct { // adjudicate its ambiguous allowance period. Helius's dedicated nodes // require the $499 Business plan the same way. RequiresPlan string `yaml:"requires_plan"` + // Chains this plan actually serves. Required on unmetered capacity: + // that path skips unitsPerRequest, which is where every other cohort's + // chain check lives, so without it an Ethereum-only node leads the + // Solana tab at an Ethereum price and a Solana node carries an + // Ethereum bill. Empty means "the weights table decides", which is + // correct for per-request plans and wrong for a dedicated node. + Chains []string `yaml:"chains"` Throughput Throughput `yaml:"throughput"` Archive string `yaml:"archive"` // true | false | gated Trace *bool `yaml:"trace"` @@ -264,6 +271,17 @@ func (c *Catalogue) validate() error { } } + if p.Cohort == "dedicated" { + for _, pl := range p.Plans { + if pl.Confidence == "unpublished" { + continue + } + if len(pl.Chains) == 0 { + return fmt.Errorf("%s/%s: dedicated capacity must declare `chains:`; without it the plan prices every chain tab, including ones it does not serve", p.Slug, pl.ID) + } + } + } + // A zero or missing archive multiplier silently zeroes every // archive workload's unit cost, which made GetBlock the cheapest // indexer and trace provider in the cohort at $0 per month. @@ -326,6 +344,19 @@ func (c *Catalogue) FreeAllowanceRequests(p Provider, pr Profile) (float64, stri if c.PlanTier(p, pl) != TierFree || pl.IncludedUnits == nil { continue } + // An allowance the plan cannot spend on this workload is not an + // allowance. dRPC's free tier led the trace panel at 10.5M requests + // while its own plan is archive: false, trace: false and the ledger + // on the same page refused it: the panel contradicted the table. + if pr.Archive && pl.Archive == "false" { + continue + } + if isTraceProfile(pr) && pl.Trace != nil && !*pl.Trace { + continue + } + if len(pl.Chains) > 0 && !servesChain(pl, pr.Chain) { + continue + } upr, err := unitsPerRequest(p, pr) if err != nil || upr <= 0 { continue diff --git a/harnesses/rpc-cost/cmd/script/main.go b/harnesses/rpc-cost/cmd/script/main.go index d6da21d59..b20aac9dd 100644 --- a/harnesses/rpc-cost/cmd/script/main.go +++ b/harnesses/rpc-cost/cmd/script/main.go @@ -2,7 +2,6 @@ package main import ( "log" - "math" "net/http" "os" "os/signal" @@ -87,11 +86,14 @@ func run(cat *Catalogue) { } func priceEverything(cat *Catalogue) { - // The cheapest metered rate across the usage cohort, used as the - // denominator for the dedicated cohort's break-even. Computed from - // the simple-read profile at the largest bucket, which is the rate a - // high-volume buyer would actually be quoted. - meteredFloor := math.Inf(1) + // The cheapest metered rate across the usage cohort, per chain, used + // as the denominator for the dedicated cohort's break-even. Computed + // from the simple-read profile at the largest bucket, the rate a + // high-volume buyer would actually be quoted. Per chain because a + // Solana node compared against an Ethereum rate answers a question + // nobody asked: every break-even used to be labelled ethereum, + // including the ones for Solana-only nodes. + meteredFloor := map[string]float64{} for _, p := range cat.Providers { if p.Cohort == "excluded" { @@ -139,8 +141,10 @@ func priceEverything(cat *Catalogue) { eligible.WithLabelValues(p.Slug, ka, ba).Set(1) } } - if cohort == "usage" && pr.ID == "simple-read" && b.ID == "1000m" && q.PerMillionUSD < meteredFloor { - meteredFloor = q.PerMillionUSD + if cohort == "usage" && pr.ID == baselineProfile(pr.Chain) && b.ID == "1000m" { + if cur, ok := meteredFloor[pr.Chain]; !ok || q.PerMillionUSD < cur { + meteredFloor[pr.Chain] = q.PerMillionUSD + } } } if tier == "all" { @@ -165,9 +169,6 @@ func priceEverything(cat *Catalogue) { } } - if math.IsInf(meteredFloor, 1) { - return - } for _, p := range cat.Providers { if p.Cohort != "dedicated" { continue @@ -177,7 +178,15 @@ func priceEverything(cat *Catalogue) { continue } monthly := cat.toUSD(*pl.MonthlyUSD, p.Currency) - breakevenReqs.WithLabelValues(p.Slug, pl.ID, "ethereum").Set(breakeven(monthly, meteredFloor)) + // One series per chain the plan actually serves, against that + // chain's own metered floor. + for _, ch := range pl.Chains { + floor, ok := meteredFloor[ch] + if !ok { + continue + } + breakevenReqs.WithLabelValues(p.Slug, pl.ID, ch).Set(breakeven(monthly, floor)) + } } } } diff --git a/harnesses/rpc-cost/cmd/script/model.go b/harnesses/rpc-cost/cmd/script/model.go index b9bea3eef..fa2011da6 100644 --- a/harnesses/rpc-cost/cmd/script/model.go +++ b/harnesses/rpc-cost/cmd/script/model.go @@ -126,6 +126,15 @@ func quote(c *Catalogue, p Provider, pl Plan, pr Profile, requests float64) Quot // workload — which is exactly what makes a break-even volume the right // question for it. Without this branch all nine offerings failed with // "provider does not price ethereum" and the cohort rendered empty. + // Capacity sold by the month serves the chains it was provisioned for + // and no others. This is the only chain test on the unmetered path, + // because that path skips unitsPerRequest, where every other cohort's + // test lives. + if len(pl.Chains) > 0 && !servesChain(pl, pr.Chain) { + q.Reason = fmt.Sprintf("plan does not serve %s", pr.Chain) + return q + } + unmetered := p.Cohort == "dedicated" var upr float64 if !unmetered { @@ -136,6 +145,7 @@ func quote(c *Catalogue, p Provider, pl Plan, pr Profile, requests float64) Quot return q } } + q.UnitsPerRequest = upr // Trace workloads on a plan that does not serve trace are not "more @@ -443,3 +453,16 @@ func weightedUnits(w Weights, pr Profile) (float64, error) { } return total, nil } + +// servesChain reports whether a plan is provisioned for a chain. An empty +// list means the weights table decides, which is right for a per-request +// plan and wrong for a dedicated node, so validate() requires the list on +// the dedicated cohort. +func servesChain(pl Plan, chain string) bool { + for _, c := range pl.Chains { + if c == chain { + return true + } + } + return false +} diff --git a/harnesses/rpc-cost/cmd/script/profiles.go b/harnesses/rpc-cost/cmd/script/profiles.go index bbe1bbb3a..90cdd6648 100644 --- a/harnesses/rpc-cost/cmd/script/profiles.go +++ b/harnesses/rpc-cost/cmd/script/profiles.go @@ -125,4 +125,19 @@ const ( headlineBucket = "10m" ) +// baselineProfile returns the profile used as a chain's reference workload +// when pricing a break-even: the first profile declared for that chain. +// Ethereum's is simple-read, Solana's is solana-bot, because that is the +// only Solana workload defined. Without a per-chain baseline the floor was +// computed on an Ethereum profile and every Solana node was dropped for +// want of a denominator. +func baselineProfile(chain string) string { + for _, pr := range Profiles { + if pr.Chain == chain { + return pr.ID + } + } + return "" +} + const daysPerMonth = 30 // stated, not assumed: every derived figure uses it diff --git a/harnesses/rpc-cost/pricing/catalogue.yml b/harnesses/rpc-cost/pricing/catalogue.yml index 4664b7457..d52af82cc 100644 --- a/harnesses/rpc-cost/pricing/catalogue.yml +++ b/harnesses/rpc-cost/pricing/catalogue.yml @@ -3348,6 +3348,7 @@ providers: currency: USD plans: - id: dedicated-eth-full + chains: [ethereum] name: Dedicated Ethereum full node (geth/nethermind/reth/reth+prysm/teku+geth) tier: enterprise monthly_usd: 1100 @@ -3366,6 +3367,7 @@ providers: Every dedicated node bundles a free $49/mo shared plan, so there is NO prerequisite. Term discounts: 1 mo base / 6 mo -10% / 12 mo -20% (annual_monthly_usd = 1100 x 0.80). - id: dedicated-eth-archive + chains: [ethereum] name: Dedicated Ethereum archive node (erigon / reth) tier: enterprise monthly_usd: 1300 @@ -3380,6 +3382,7 @@ providers: confidence: verified note: geth archive is a separate, much dearer SKU at $2,400/mo. - id: dedicated-sol-full + chains: [solana] name: Dedicated Solana full node tier: enterprise monthly_usd: 2900 @@ -3394,6 +3397,7 @@ providers: confidence: verified note: $3,200 for the high-performance variant. There is NO Solana dedicated archive SKU. - id: limitless-evm-full-25 + chains: [ethereum] name: Limitless Node, EVM full, 25 RPS tier: growth monthly_usd: 150 @@ -3412,6 +3416,7 @@ providers: "No shared or dedicated plan required." "No usage-based billing and no overage charges." 15 chains. Ladder: 25/50/150/300 RPS at $150/$300/$500/$1,000. - id: limitless-evm-archive-15 + chains: [ethereum] name: Limitless Node, EVM archive, 15 RPS tier: growth monthly_usd: 150 @@ -3426,6 +3431,7 @@ providers: confidence: verified note: 'Same price ladder as full, lower RPS: 15/30/50/100 RPS at $150/$300/$500/$1,000.' - id: limitless-sol-full-5 + chains: [solana] name: Limitless Node, Solana full, 5 RPS tier: growth monthly_usd: 150 @@ -3469,6 +3475,7 @@ providers: currency: USD plans: - id: unlimited-25 + chains: [ethereum, solana] name: Unlimited Node add-on, 25 RPS (+ Growth $49) tier: growth monthly_usd: 198 @@ -3487,6 +3494,7 @@ providers: incurs overage." Ladder: 25/100/250/500 RPS at $149/$649/$1,649/$3,199 (+$49 each); a 1000-RPS tier exists in the docs with NO PUBLISHED PRICE. - id: unlimited-100 + chains: [ethereum, solana] name: Unlimited Node add-on, 100 RPS (+ Growth $49) tier: business monthly_usd: 698 @@ -3500,6 +3508,7 @@ providers: trace: true confidence: verified - id: unlimited-250 + chains: [ethereum, solana] name: Unlimited Node add-on, 250 RPS (+ Growth $49) tier: enterprise monthly_usd: 1698 @@ -3513,6 +3522,7 @@ providers: trace: true confidence: verified - id: unlimited-500 + chains: [ethereum, solana] name: Unlimited Node add-on, 500 RPS (+ Growth $49) tier: enterprise monthly_usd: 3248 @@ -3526,6 +3536,7 @@ providers: trace: true confidence: verified - id: dedicated-eth-full-geth + chains: [ethereum] name: Dedicated Node, Ethereum full, geth 2-C16-R64-S 2,501 GB (+ Pro $199) tier: enterprise monthly_usd: 1841.50 @@ -3545,6 +3556,7 @@ providers: "$0.50 vs $0.10" conflict decisively in favour of $0.50. Storage and egress are folded into the compute hour. No RPS cap is published. - id: dedicated-eth-archive-erigon + chains: [ethereum] name: Dedicated Node, Ethereum archive, erigon 3-C32-R128-M 3,652 GB (+ Pro $199) tier: enterprise monthly_usd: 2629.90 @@ -3561,6 +3573,7 @@ providers: Node $2,430.90/mo [x730], decomposing exactly as (3 x $0.50 x 730) + (183 storage packages x $0.01 x 730) = $1,095.00 + $1,335.90. Plus the Pro $199 prerequisite. - id: dedicated-sol-full + chains: [solana] name: Dedicated Node, Solana full, 6-C128-R1024-M 3,800 GB (+ Pro $199) tier: enterprise monthly_usd: 3776.00 @@ -3609,6 +3622,7 @@ providers: currency: USD plans: - id: evm-75 + chains: [ethereum] name: Flat Rate RPS, EVM 75 RPS (+ Build $49) tier: business monthly_usd: 848 @@ -3628,6 +3642,7 @@ providers: API credits"; excess gets 429 with no overage. EVM ladder 75/150/250 RPS at $799/$1,499/$1,949. Enterprise accounts cannot self-serve this. - id: evm-150 + chains: [ethereum] name: Flat Rate RPS, EVM 150 RPS (+ Build $49) tier: enterprise monthly_usd: 1548 @@ -3641,6 +3656,7 @@ providers: trace: true confidence: verified - id: evm-250 + chains: [ethereum] name: Flat Rate RPS, EVM 250 RPS (+ Build $49) tier: enterprise monthly_usd: 1998 @@ -3654,6 +3670,7 @@ providers: trace: true confidence: verified - id: sol-75 + chains: [solana] name: Flat Rate RPS, Solana 75 RPS (+ Build $49) tier: enterprise monthly_usd: 1248 @@ -3725,6 +3742,7 @@ providers: currency: USD plans: - id: eth-full + chains: [ethereum] name: Ethereum full node (Geth) tier: business monthly_usd: 503.70 @@ -3743,6 +3761,7 @@ providers: single-tenant node in the cohort — roughly half GetBlock's $1,100 — but Ethereum-only, 3 regions, and no SLA or RPS guarantee is published. - id: eth-archive + chains: [ethereum] name: Ethereum archive node (Erigon) tier: enterprise monthly_usd: 2000.20 @@ -3786,6 +3805,7 @@ providers: currency: USD plans: - id: mainnet-floor + chains: [solana] name: Dedicated Solana node, mainnet (+ Business $499) tier: enterprise monthly_usd: 3399 @@ -3836,6 +3856,7 @@ providers: currency: USD plans: - id: free + chains: [solana] name: Free tier: free monthly_usd: 0 @@ -3852,6 +3873,7 @@ providers: "Unlimited Credits" — there is NO volume unit on the current plans, only an RPS ceiling. 10 RPC/s, 0 index/s, 1 sendTransaction/s. At the ceiling you get 429, not a bill. - id: build + chains: [solana] name: Build tier: growth monthly_usd: 199 @@ -3868,6 +3890,7 @@ providers: 100 RPC/s, 10 index/s, 20 sendTransaction/s, 10 gRPC connections. "No Credits. No Bandwidth Limits. No Overages." Extra gRPC connections $100 per 10. - id: grow + chains: [solana] name: Grow tier: business monthly_usd: 349 @@ -3884,6 +3907,7 @@ providers: 150 RPC/s, 20 gRPC connections. The secondary index/API RPS column CONFLICTS between two primaries: the pricing page says 30/s, the docs say 20/s. Both live, same date. - id: accelerate + chains: [solana] name: Accelerate tier: business monthly_usd: 649 @@ -3898,6 +3922,7 @@ providers: confidence: uncertain note: '400 RPC/s, 50 gRPC connections. Same index-RPS conflict: pricing page 100/s vs docs 40/s.' - id: dedicated + chains: [solana] name: Dedicated Solana RPC / gRPC node tier: enterprise monthly_usd: 1800 @@ -3912,6 +3937,7 @@ providers: confidence: uncertain note: '"Starting from $1800/mo" — a floor. Gen5 AMD with shred acceleration. Everything above the entry price is SALES-GATED.' - id: legacy-scale + chains: [solana] name: Legacy Scale (credit-based, still listed) tier: business monthly_usd: 499 @@ -3961,6 +3987,7 @@ providers: currency: USD plans: - id: eth-advanced + chains: [ethereum] name: Ethereum full node, Advanced tier: business monthly_usd: 320 @@ -3978,6 +4005,7 @@ providers: and you reach it directly. The right self-host reference row, and it must be labelled as such. Annual is -25%. Archive add-on +$320 (=> $640); failover add-on +$160. No meter at all. - id: eth-enterprise + chains: [ethereum] name: Ethereum full node, Enterprise tier: business monthly_usd: 640 @@ -3992,6 +4020,7 @@ providers: confidence: verified note: Archive add-on +$640 (=> $1,280); failover +$320. - id: sol-advanced + chains: [solana] name: Solana full node, Advanced tier: enterprise monthly_usd: 1920 @@ -4037,6 +4066,7 @@ providers: currency: USD plans: - id: eth-basic + chains: [ethereum] name: Ethereum Basic (dedicated RPC) tier: growth monthly_usd: 149 @@ -4057,6 +4087,7 @@ providers: by arithmetic; that label sits on a duplicated markup block and is simply wrong. Bandwidth 100 Mbps. NO RPS FIGURE IS PUBLISHED AT ANY TIER. - id: eth-advance + chains: [ethereum] name: Ethereum Advance tier: growth monthly_usd: 299 @@ -4075,6 +4106,7 @@ providers: price at 100% saturation of an unverified ceiling — 2B/month is 771 RPS sustained and Zeeve publishes no RPS at all. - id: eth-enterprise + chains: [ethereum] name: Ethereum Enterprise tier: business monthly_usd: 599 @@ -4089,6 +4121,7 @@ providers: confidence: uncertain note: '"Unmetered", 3 Gbps.' - id: sol-basic + chains: [solana] name: Solana Basic tier: business monthly_usd: 640 @@ -4134,6 +4167,7 @@ providers: currency: USD plans: - id: bc-t3-large-us-east-1 + chains: [ethereum] name: bc.t3.large node, us-east-1 tier: growth monthly_usd: 97.82 @@ -4152,6 +4186,7 @@ providers: standard AWS data-transfer charges. Quoting AWS at "$3/1M" alone understates it by roughly a third at 100M requests/month. Larger instances run to $897.17/mo (bc.m5.4xlarge). - id: bc-t3-large-eu-west-1 + chains: [ethereum] name: bc.t3.large node, eu-west-1 tier: growth monthly_usd: 97.82 @@ -4166,6 +4201,7 @@ providers: confidence: uncertain note: Same $0.134/node-hour [x730]; only the per-request rate differs by region. Same 32 KB / 500 ms unit caveat and same end-of-life dates. - id: bc-t3-large-ap-southeast-1 + chains: [ethereum] name: bc.t3.large node, ap-southeast-1 tier: growth monthly_usd: 97.82 diff --git a/src/lib/citation.test.ts b/src/lib/citation.test.ts index 2dca92d0f..6c95ad915 100644 --- a/src/lib/citation.test.ts +++ b/src/lib/citation.test.ts @@ -235,3 +235,37 @@ describe("all-zero leaderboard", () => { expect(leader(b)?.slug).toBe("a"); }); }); + +describe("citation, a headline measured on a slice", () => { + const cost = (dimensions: Benchmark["dimensions"]): Benchmark => ({ + ...bench([r("blockpi", "BlockPI", 29.5), r("alchemy", "Alchemy", 125)]), + slug: "rpc-cost", + metric: "Monthly cost", + unit: "usd", + dimensions, + }); + + test("names the slice when the default view is one", () => { + // rpc-cost's headline holds for one of fifteen cells, and this sentence + // is what /api/stat, /api/citable and llms.txt hand to an answer engine. + // Leaving the slice out published the single "cheapest" the page's own + // intro says cannot be honest. + const b = cost({ + kind: [{ value: "all", label: "Dapp frontend" }, { value: "trace", label: "Trace and debug" }], + bucket: [{ value: "all", label: "10M requests/mo" }, { value: "100m", label: "100M requests/mo" }], + venue: [{ value: "all", label: "Cheapest paid plan" }, { value: "free", label: "Free tier" }], + }); + const s = headlineSentence(b); + expect(s).toContain("Dapp frontend"); + expect(s).toContain("10M requests/mo"); + expect(s).toContain("Cheapest paid plan"); + }); + + test("stays silent when the default view is a real aggregate", () => { + // Naming "All chains" would be noise on every latency bench. + const b = cost({ + chain: [{ value: "all", label: "All chains" }, { value: "solana", label: "Solana" }], + }); + expect(headlineSentence(b)).not.toContain("measured on"); + }); +}); diff --git a/src/lib/citation.ts b/src/lib/citation.ts index 4346a7625..ed45cfc3c 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -262,7 +262,7 @@ export function headlineParts(b: Benchmark): { claim: string; rest: string } { // checkable, and not the page title (55 to 95 characters that every // quoting surface repeated, audit 2026-09-22). const ranked = rankedCandidates(b).length; - const rest = `${windowSuffix(b)} across ${ranked} ranked ${nounFor(b, ranked)}.`; + const rest = `${windowSuffix(b)} across ${ranked} ranked ${nounFor(b, ranked)}${sliceClause(b)}.`; const tied = leaderNames(b); if (tied.length > 1) { const both = tied.length === 2; @@ -287,6 +287,37 @@ export function headlineParts(b: Benchmark): { claim: string; rest: string } { }; } +/** The slice a headline figure was measured on, when the default view is a + * slice rather than an aggregate. + * + * A bench that declares dimensions answers a different question per tab. + * rpc-cost's headline ("BlockPI posts the lowest monthly cost at $29.50") + * holds for the dapp workload, at ten million requests a month, on the + * cheapest paid plan, and for none of the other fourteen cells. That + * sentence is what /api/stat, /api/citable and llms.txt hand to an answer + * engine, so leaving the slice out publishes the single "cheapest" the + * page's own intro says cannot be honest. + * + * A dimension whose first value reads "All ..." is a real aggregate and is + * skipped, so a chain- or region-dimensioned bench is unaffected. `tier` + * is skipped too: it partitions the provider list rather than the + * measurement, and the cohort size is already in the sentence. + */ +function sliceClause(b: Benchmark): string { + const dims = b.dimensions; + if (!dims) return ""; + const parts: string[] = []; + for (const key of ["kind", "bucket", "venue"] as const) { + const first = dims[key]?.[0]; + if (!first) continue; + const label = first.label.trim(); + if (/^all\b/i.test(label)) continue; + parts.push(label.replace(/\s*\(default\)\s*$/i, "")); + } + if (parts.length === 0) return ""; + return `, measured on ${parts.join(", ")}`; +} + /** Pasteable attribution string. Standard convention: " Source: OpenChainBench (url)". */ export function citationQuote(b: Benchmark, origin: string): string { const sentence = headlineSentence(b); From ac59dff13ecd6d97bb005fb7fa6c1024f5eb7572 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:37:25 +0200 Subject: [PATCH 013/187] a contact page, and the mailto stops being invisible (#2755) There was no contact page. The address lived in the footer and on three other pages, so every kind of request arrived in one inbox with no context, and the pages carrying it drew about 1 % of the traffic. The page routes a request to the channel that answers it. A wrong number goes to the data-quality template, which asks for the bench slug and the value and is therefore checkable. A provider correcting their own entry goes to the provider template, which asks who they are so the change can be attributed. A benchmark proposal goes through an issue before anyone writes a harness. An integrity problem goes to a private advisory rather than a public issue, which is the one case where filing in the open makes things worse. Email stays last and unconditional. A contact page that only offers forms turns people away, and the ones most worth hearing from are often the ones who will not open a GitHub account to tell us we are wrong. No form on the page either: it would put a database between the reader and us for no gain. A section says what we cannot do, because those three requests arrive anyway and a page that stays silent about them invites them. The second half is why this was worth doing at all. The outbound-click listener returns early on any protocol that is not http(s), which is correct for the cursor:// and vscode: deeplinks on /mcp and silently dropped every mailto. The one contact channel the site had was the one surface it never measured, so "nobody wrote to us" and "we never looked" were the same number: zero. A mailto now fires contact_click with the page it was clicked from, and no address, since there is one and it is already public. Also: the sitemap gained a duplicate /contact while this was being written, because a grep for "/contact" does not match a template literal spelled ${SITE.url}/contact. That is the same defect that put /chains/cosmos-hub in the production sitemap twice yesterday, so the static route list now has a test that fails on any repeat. The footer comment counted its own links and named which ones the sitemap lacks. Adding one made both halves wrong, so both are corrected. --- src/app/contact/page.tsx | 109 ++++++++++++++++++++++++++ src/components/posthog-provider.tsx | 8 ++ src/components/site-footer.tsx | 23 +++--- src/components/site-nav-items.ts | 8 ++ src/lib/analytics.ts | 5 ++ src/lib/sitemap-builder.ts | 1 + src/lib/sitemap-static-routes.test.ts | 40 ++++++++++ 7 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/app/contact/page.tsx create mode 100644 src/lib/sitemap-static-routes.test.ts diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 000000000..f1719a7a8 --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,109 @@ +import type { Metadata } from "next"; +import { SectionRule } from "@/components/section-rule"; +import { pageMetadata } from "@/lib/page-metadata"; +import { SITE } from "@/data/site"; +import { withUtm } from "@/lib/utm"; + +export const metadata: Metadata = pageMetadata({ + path: "/contact", + title: "Contact", + description: + "How to reach OpenChainBench: correct a number, correct your own provider entry, propose a benchmark, report an integrity problem, or ask about press and embeds.", +}); + +/** + * One page that routes a request to the channel that answers it. + * + * The address existed before this page, in the footer and on three other + * pages, and that was the whole system: every kind of request arrived in + * one inbox with no context. Most of them have a better destination that + * already exists. A wrong number belongs in the data-quality template, + * where the form asks for the bench slug and the value; a provider + * correcting their own entry belongs in the provider template, which asks + * who they are; an integrity problem belongs in a private advisory rather + * than in a public issue. + * + * Email stays last and unconditional. A contact page that only offers + * forms is a contact page that turns people away, and the people most + * worth hearing from are often the ones who will not open a GitHub + * account to tell us we are wrong. + */ +const ROUTES = [ + { + title: "A number on the site looks wrong", + body: "Every figure is a query against a public Prometheus, so a wrong number is reproducible and worth a report. The template asks for the benchmark, the value you saw and what you expected, which is what makes it checkable.", + href: "https://github.com/ChainBench/OpenChainBench/issues/new?template=data-quality.yml", + cta: "Open a data-quality issue", + }, + { + title: "You are a provider and something about you is wrong", + body: "Endpoint, plan tier, pricing, the description on your product page, or a cohort you believe you should be in. The template asks you to identify yourself so the correction can be attributed in the changelog.", + href: "https://github.com/ChainBench/OpenChainBench/issues/new?template=provider-correction.yml", + cta: "Open a provider correction", + }, + { + title: "You want a benchmark that does not exist", + body: "Proposals go through an issue first so the metric, the providers and the methodology get reviewed before anyone writes a harness. Brainstorm-stage ideas belong in Discussions instead.", + href: "https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml", + cta: "Propose a benchmark", + }, + { + title: "You think our independence has been compromised", + body: "No paid tier, no sold ranking slots, no provider sponsorship in exchange for inclusion. If you spot a deviation from that, it is an integrity incident and it should not be filed in public first.", + href: "https://github.com/ChainBench/OpenChainBench/security/advisories/new", + cta: "File a private advisory", + }, +] as const; + +export default function ContactPage() { + return ( +
    +
    +

    Contact

    +

    + Most requests have a destination that answers them faster than an inbox. The address at the bottom works for everything else. +

    +
    + + +
    + + +

    + Press, embeds, partnerships, a correction you would rather not file in public, or anything that does not fit above. +

    +

    + + {SITE.email} + +

    +

    + There is no form on this page on purpose: a form would put a database between you and us for no gain. Journalists will find the logos, the boilerplate and the quick facts on the{" "} + press kit, and the embed and badge terms are on{" "} + partners. +

    + + +
      +
    • Move a provider up a leaderboard. The order is recomputed at render time from the measured values, and no one here can edit it.
    • +
    • Remove a provider from a benchmark because the result is unflattering. A correction to the measurement is always welcome; a correction to the outcome is not a thing we have.
    • +
    • Give support for a provider's product. We measure them from the outside and have no account with most of them.
    • +
    +
    + ); +} diff --git a/src/components/posthog-provider.tsx b/src/components/posthog-provider.tsx index 9feb5bacd..b78cb41e4 100644 --- a/src/components/posthog-provider.tsx +++ b/src/components/posthog-provider.tsx @@ -23,6 +23,14 @@ function OutboundClicks() { } catch { return; } + // The mailto is the site's only contact channel and the only one + // this listener used to drop, so "nobody wrote to us" and "we never + // looked" were the same number. Counted separately, and without the + // address: there is one, and it is on the page already. + if (url.protocol === "mailto:") { + track("contact_click", { page: window.location.pathname }); + return; + } if (url.protocol !== "http:" && url.protocol !== "https:") return; if (url.host === window.location.host) return; track("outbound_click", { diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index a1e9edbda..e6b7bf901 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -13,18 +13,20 @@ import { withUtm } from "@/lib/utm"; * territory, so all three were repetition paying for a third of the * viewport. * - * What did NOT change is the link set. None of these ten appear in the - * left rail: the rail carries the editorial sections (/benchmarks, - * /perps, /rpc and so on) and this carries the developer and project - * surfaces. + * What did NOT change is the link set. These are the developer and + * project surfaces; the left rail carries the editorial sections + * (/benchmarks, /perps, /rpc and so on). /contact is the one link that + * sits in both, because the rail is where someone looks for it and the + * footer is where they happen to be when they need it. * - * Before pruning this row, check the sitemap. FOUR of these ten are - * absent from it, so for each of them this footer is the only inbound - * link on the site: /llms.txt, /api/citable, /api/openapi.json and + * Before pruning this row, check the sitemap. FOUR of these are absent + * from it, so for each of them this footer is the only inbound link on + * the site: /llms.txt, /api/citable, /api/openapi.json and * /api/llm-context. Dropping any one orphans a file the citability work - * exists to serve. (/partners, /badges, /team and /press are in the - * sitemap and would survive a cut here.) An earlier version of this note - * named only the first two, which read as a complete list and was not. + * exists to serve. (/partners, /badges, /team, /press and /contact are + * in the sitemap and would survive a cut here.) An earlier version of + * this note named only the first two, which read as a complete list and + * was not, and a later one said "ten" when counting was the point. */ const DEVELOPER_LINKS = [ { label: "Partners + embeds", href: "/partners" }, @@ -38,6 +40,7 @@ const DEVELOPER_LINKS = [ const PROJECT_LINKS = [ { label: "Team", href: "/team" }, { label: "Press kit", href: "/press" }, + { label: "Contact", href: "/contact" }, ]; export function SiteFooter() { diff --git a/src/components/site-nav-items.ts b/src/components/site-nav-items.ts index 858def80a..1a3ca39cf 100644 --- a/src/components/site-nav-items.ts +++ b/src/components/site-nav-items.ts @@ -14,6 +14,7 @@ import { Info, Landmark, Layers, + Mail, MapPin, PiggyBank, Scale, @@ -188,6 +189,13 @@ export function navGroups(hiddenRoutes: readonly string[] = []): NavGroup[] { inHeader: true, mdHidden: true, }, + { + href: "/contact", + label: "Contact", + icon: Mail, + match: (p) => p === "/contact", + mdHidden: true, + }, ], }, ]; diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 00e816fb7..fe78281bf 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -4,6 +4,7 @@ * sites never guard. * * outbound_click {href, host, text, page} a link to another site, delegated listener + * contact_click {page} the email address, same listener * copy {kind, value?, bench?} endpoint / API URL / MCP URL or config / embed / brief * search {query, kind, url} a result picked in the search dialog * search_no_result {query} a query that matched nothing (content gaps) @@ -16,6 +17,10 @@ import posthog from "posthog-js"; export type SiteEvent = | { name: "outbound_click"; props: { href: string; host: string; text: string; page: string } } + /** The mailto. outbound_click only fires on http(s), so the one contact + * channel the site has was the one surface it never measured. No + * address is sent: there is only one, and it is already public. */ + | { name: "contact_click"; props: { page: string } } | { name: "copy"; props: { kind: "endpoint" | "api_url" | "mcp_url" | "mcp_config" | "embed" | "brief" | "other"; value?: string; bench?: string } } | { name: "search"; props: { query: string; kind: string; url: string } } | { name: "search_no_result"; props: { query: string } } diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts index d9f7129dc..1c0ccaeb8 100644 --- a/src/lib/sitemap-builder.ts +++ b/src/lib/sitemap-builder.ts @@ -217,6 +217,7 @@ function staticHubRoutes(catalogTs: Date): MetadataRoute.Sitemap { ...(isDevOnlyRoute("/rpc-map") ? [] : [{ url: `${SITE.url}/rpc-map`, lastModified: pageMtime("rpc-map/page.tsx"), changeFrequency: "daily" as const, priority: 0.8 }]), + { url: `${SITE.url}/contact`, lastModified: pageMtime("contact/page.tsx"), changeFrequency: "monthly", priority: 0.6 }, { url: `${SITE.url}/fee-compare`, lastModified: pageMtime("fee-compare/page.tsx"), changeFrequency: "monthly", priority: 0.7 }, { url: `${SITE.url}/methodology`, lastModified: pageMtime("methodology/page.tsx"), changeFrequency: "monthly", priority: 0.7 }, { url: `${SITE.url}/contribute`, lastModified: pageMtime("contribute/page.tsx"), changeFrequency: "monthly", priority: 0.7 }, diff --git a/src/lib/sitemap-static-routes.test.ts b/src/lib/sitemap-static-routes.test.ts new file mode 100644 index 000000000..562a6f191 --- /dev/null +++ b/src/lib/sitemap-static-routes.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * The static hub block of the sitemap is a hand-maintained list, which is + * exactly the kind of list that grows a duplicate. It has happened twice: + * /chains/cosmos-hub shipped twice in the production sitemap on + * 2026-09-30, and /contact was added twice while this file was being + * written, because a grep for "/contact" does not match a template + * literal spelled `${SITE.url}/contact`. + * + * A duplicate is not fatal, but it is a signal to a crawler that + * something generates the file badly, and it inflates the URL count the + * smoke test reads. Reading the source is enough to catch it: the + * entries are literals. + */ +const SRC = readFileSync(path.join(process.cwd(), "src/lib/sitemap-builder.ts"), "utf8"); + +function staticRoutes(): string[] { + // `${SITE.url}/some/path` inside the entry list + return [...SRC.matchAll(/\$\{SITE\.url\}(\/[A-Za-z0-9/_-]*)`/g)].map((m) => m[1]); +} + +describe("sitemap static routes", () => { + test("the list is not empty, so the regex still matches the source", () => { + expect(staticRoutes().length).toBeGreaterThan(10); + }); + + test("no route is listed twice", () => { + const seen = new Map(); + for (const r of staticRoutes()) seen.set(r, (seen.get(r) ?? 0) + 1); + const dupes = [...seen.entries()].filter(([, n]) => n > 1).map(([r, n]) => `${r} x${n}`); + expect(dupes).toEqual([]); + }); + + test("/contact is listed", () => { + expect(staticRoutes()).toContain("/contact"); + }); +}); From ecc3396af1a94f8b7b27141d566524e4715b6ad1 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:13:51 +0200 Subject: [PATCH 014/187] a docs page, so the Docs group in the nav finally has one (#2757) * a docs page, so the Docs group in the nav finally has one The left rail had a group labelled Docs holding Methodology, API & MCP, Contribute, About and Contact. There was no page that said what this site exposes to a machine, so the answer was spread across /mcp, the OpenAPI document, llms.txt and whatever a reader happened to find. /docs is that index. The MCP server and its four tools, the eleven REST endpoints the OpenAPI document declares, the paths that answer Accept: text/markdown, the files written for agents, the four citation formats, the badges, the feeds, and the same data on Hugging Face. It does not repeat /mcp. That page is the install flow for Claude Desktop, Cursor and VS Code with the deeplinks and the JSON blocks; this one says what the server is and links there. Nothing here was written from memory. Every endpoint, transport, format and path was probed against production while the page was written: - the four tools come from tools/list on the live server, not from the route source - the eleven endpoints are the ones /api/openapi.json declares, each probed, including /api/og/{slug}, which is served through a rewrite and so does not appear as a route file - the citation formats are bib, ris, apa and txt. Not bibtex, which answers 400 with the allowed list - Markdown is served for /benchmarks/{slug}, /products/{slug}, /perps, /rwa and /capital, and the page says so rather than implying every page, because /rpc and /chains/{slug} answer HTML whatever the Accept header asks for - the MCP rate limit is rateLimit(key, 60, 60), which is 60 a minute - both Zenodo DOIs are named and distinguished, because the record at ...312 declares ...311 as its conceptdoi and the two are not interchangeable: the concept DOI follows the newest version, the version DOI is the one that keeps a result reproducible The two facts most likely to rot say where the live truth is: tools/list for the tools, and the OpenAPI document for the endpoints, which is generated from the routes rather than hand-written. * docs: /api/citable goes through Link, like the footer already does next/no-html-link-for-pages resolves /api/citable/ as a known route because /api/citable/[date] exists next to it, so the bare was an error rather than a warning. The footer has used for the /api surfaces since it was written; this matches it. --- src/app/docs/page.tsx | 222 +++++++++++++++++++++++++++++++ src/components/site-nav-items.ts | 8 ++ src/lib/sitemap-builder.ts | 1 + 3 files changed, 231 insertions(+) create mode 100644 src/app/docs/page.tsx diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx new file mode 100644 index 000000000..5d5e30f01 --- /dev/null +++ b/src/app/docs/page.tsx @@ -0,0 +1,222 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { SectionRule } from "@/components/section-rule"; +import { pageMetadata } from "@/lib/page-metadata"; +import { SITE } from "@/data/site"; +import { withUtm } from "@/lib/utm"; + +export const metadata: Metadata = pageMetadata({ + path: "/docs", + title: "Docs", + description: + "Every machine-readable surface OpenChainBench publishes: the MCP server and its four tools, eleven REST endpoints with an OpenAPI 3.1 spec, Markdown views of the pages, llms.txt, four citation formats under CC BY 4.0, embeddable SVG badges and the feeds.", +}); + +/** + * The index of everything a machine can read here. + * + * Nothing on this page is written from memory. Every endpoint, format, + * transport and path below was probed against production while the page + * was written, and the few that are not reachable are either absent or + * stated as such. The two facts most likely to rot are the tool list and + * the endpoint table, so both name where the live truth is: tools/list on + * the server itself, and /api/openapi.json, which is generated rather + * than hand-written. + * + * It does NOT repeat /mcp. That page is the install flow for Claude + * Desktop, Cursor and VS Code, with the deeplinks and the JSON blocks; + * this one says what the server is and sends you there. + */ + +const MCP_URL = `${SITE.url}/api/mcp/mcp`; + +const TOOLS = [ + { + name: "list_benchmarks", + body: "Flat index of every published benchmark with its current headline value, leader, category, unit and citation URL. The discovery call.", + }, + { + name: "get_benchmark", + body: "One benchmark in full: every provider ranked by p50, a 24 h sparkline, the headline sentence, a paste-ready citation, the methodology bullets and the source-code URL. Takes chain and region where the bench declares them.", + }, + { + name: "list_answers", + body: "Every answer page: a question in plain words, the sentence that answers it from live data, and the benchmark the number came from. For questions asked in words rather than by bench name.", + }, + { + name: "query_prom", + body: "PromQL passthrough, scoped to the benchmark metric namespaces, for ranges and shapes the two tools above do not cover.", + }, +] as const; + +/** The eleven paths the OpenAPI document declares. */ +const ENDPOINTS = [ + { path: "/api/stat/{slug}", type: "JSON", body: "One benchmark: rankings, sparkline, and a quote ready to paste. Takes ?chain=, ?region= and ?tier= where the bench declares them." }, + { path: "/api/citable", type: "JSON", body: "Flat index of every citable benchmark with its current value." }, + { path: "/api/citable/{date}", type: "JSON", body: "The same index as it stood on one past day." }, + { path: "/api/cite/{slug}/{format}", type: "bib · ris · apa · txt", body: "One citation record with the right MIME type, so Zotero and EndNote import it directly." }, + { path: "/api/compare/{a}/{b}", type: "JSON", body: "Two providers head to head on every benchmark that measures both." }, + { path: "/api/capital", type: "JSON", body: "The /capital hub in one payload: chains, protocols and the valuation rows." }, + { path: "/api/freshness", type: "JSON", body: "Last resolved data timestamp. A cheap probe before a heavier call." }, + { path: "/api/llm-context", type: "Markdown", body: "Every benchmark, ranking and methodology note as one document." }, + { path: "/api/search/featured", type: "JSON", body: "Live leaders and trending benchmarks." }, + { path: "/api/sparkline/{slug}", type: "SVG", body: "The leader's 24 h series as a standalone image." }, + { path: "/api/og/{slug}", type: "PNG", body: "A 1200x630 card with the current value and sparkline." }, +] as const; + +/** The paths that answer Markdown. Checked individually: /rpc and + * /chains/{slug} do NOT, and saying they did would waste a reader's + * afternoon. */ +const MARKDOWN_PATHS = ["/benchmarks/{slug}", "/products/{slug}", "/perps", "/rwa", "/capital"]; + +function Code({ children }: { children: React.ReactNode }) { + return ( +
    +      {children}
    +    
    + ); +} + +export default function DocsPage() { + return ( +
    +
    +

    Docs

    +

    + Every surface here is readable by a machine without a browser. One MCP server, eleven REST endpoints, the pages themselves as Markdown, and the data under CC BY 4.0. +

    +
    + + +
    + + +

    + Streamable HTTP, no auth, 60 requests per minute per IP. The mcp transport is the only one served. +

    + {MCP_URL} +

    Four tools:

    +
      + {TOOLS.map((t) => ( +
    • + {t.name} +

      {t.body}

      +
    • + ))} +
    +

    + The server answers tools/list, which is the version of this list that cannot go stale. +

    + + +

    + No key, no signup. Responses are cached at the edge, so a client that polls faster than the data moves is served from cache rather than rate-limited. +

    +
    + + + + + + + + + + {ENDPOINTS.map((e) => ( + + + + + + ))} + +
    PathReturnsWhat it is
    {e.path}{e.type}{e.body}
    +
    +

    + The OpenAPI document is generated from the routes, so it is the one to trust if this table and it ever disagree. +

    + + +

    + Send Accept: text/markdown and these paths answer with the table instead of the DOM. An agent gets the numbers without parsing HTML. +

    + {`curl -H 'Accept: text/markdown' ${SITE.url}/benchmarks/ethereum-rpc`} +

    + Served for: {MARKDOWN_PATHS.map((p, i) => ( + + {p} + {i < MARKDOWN_PATHS.length - 1 ? ", " : ""} + + ))}. Other paths answer HTML whatever the header says. +

    + + +
      +
    • + /llms.txt · the site in the llms.txt convention: what each section holds and when to cite it. +
    • +
    • + /llms-full.txt · the same, expanded, with the benchmark catalogue inline. +
    • +
    • + /api/llm-context · every ranking and methodology note as one Markdown document. +
    • +
    + + +

    + The data is CC BY 4.0. Attribution is OpenChainBench plus the benchmark page. The dataset has two Zenodo DOIs and they are not interchangeable. 10.5281/zenodo.20800311 is the concept DOI and always resolves to the newest version, which is what a living reference wants. 10.5281/zenodo.20800312 is the current version, v1.0.1-dataset, and is the one to cite when a result has to stay reproducible. +

    + {`${SITE.url}/api/cite/ethereum-rpc/bib # BibTeX +${SITE.url}/api/cite/ethereum-rpc/ris # RIS, for Zotero and EndNote +${SITE.url}/api/cite/ethereum-rpc/apa # APA, as plain text +${SITE.url}/api/cite/ethereum-rpc/txt # one sentence with the value and date`} +

    + A benchmark page moves. /api/citable is the index as it stands now, and /api/citable/{"{date}"} is that index as it stood on a past day, which is what a paper should point at. +

    + + +

    + An SVG badge with a live value, for a README or a status page. The badges catalog has one per benchmark and provider, and partners carries the embed terms. +

    + {`${SITE.url}/api/badge/ethereum-rpc/drpc`} + + + + + +
      +
    • + Hugging Face dataset · the catalogue as a dataset, for notebooks and training sets. +
    • +
    • + Hugging Face Space · the leaderboard as a hosted app. +
    • +
    • + GitHub · the specs, every harness, and the site itself. MIT for the code. +
    • +
    + + +

    + A broken endpoint or a number that does not reproduce is worth a report, and both have a destination on the contact page. Adding a benchmark of your own is contribute. +

    +
    + ); +} diff --git a/src/components/site-nav-items.ts b/src/components/site-nav-items.ts index 1a3ca39cf..dfeb1def9 100644 --- a/src/components/site-nav-items.ts +++ b/src/components/site-nav-items.ts @@ -1,6 +1,7 @@ import { ArrowLeftRight, BookOpen, + BookMarked, Boxes, ChartColumn, CircleHelp, @@ -165,6 +166,13 @@ export function navGroups(hiddenRoutes: readonly string[] = []): NavGroup[] { { label: "Docs", items: [ + { + href: "/docs", + label: "Docs", + icon: BookMarked, + match: (p) => p === "/docs", + inHeader: true, + }, { href: "/methodology", label: "Methodology", diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts index 1c0ccaeb8..807b4db08 100644 --- a/src/lib/sitemap-builder.ts +++ b/src/lib/sitemap-builder.ts @@ -217,6 +217,7 @@ function staticHubRoutes(catalogTs: Date): MetadataRoute.Sitemap { ...(isDevOnlyRoute("/rpc-map") ? [] : [{ url: `${SITE.url}/rpc-map`, lastModified: pageMtime("rpc-map/page.tsx"), changeFrequency: "daily" as const, priority: 0.8 }]), + { url: `${SITE.url}/docs`, lastModified: pageMtime("docs/page.tsx"), changeFrequency: "monthly", priority: 0.8 }, { url: `${SITE.url}/contact`, lastModified: pageMtime("contact/page.tsx"), changeFrequency: "monthly", priority: 0.6 }, { url: `${SITE.url}/fee-compare`, lastModified: pageMtime("fee-compare/page.tsx"), changeFrequency: "monthly", priority: 0.7 }, { url: `${SITE.url}/methodology`, lastModified: pageMtime("methodology/page.tsx"), changeFrequency: "monthly", priority: 0.7 }, From b163ad3320da6dcfa5c5448ee9dc0a9978d7189c Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:24:58 +0200 Subject: [PATCH 015/187] /docs and /contact were the only two pages with no share card (#2760) An SEO pass over the pages shipped today found both of them missing og:image. 39 routes carry their own opengraph-image.tsx; these two were the exception, and the root one does not cascade, so a link to either unfurled on X, Slack or LinkedIn as a bare URL with no picture. Both now render through the same renderHubOG template as every other hub, with the same 24 h revalidate: the card changes slowly and unfurlers fetch it constantly, so regenerating ~1-2 s of satori CPU per request is what the revalidate exists to avoid. The /docs meta description was 254 characters, which Google truncates at roughly 160. Rewritten at 148, which is one line a search result can actually show. --- src/app/contact/opengraph-image.tsx | 18 ++++++++++++++++++ src/app/docs/opengraph-image.tsx | 19 +++++++++++++++++++ src/app/docs/page.tsx | 4 +++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/app/contact/opengraph-image.tsx create mode 100644 src/app/docs/opengraph-image.tsx diff --git a/src/app/contact/opengraph-image.tsx b/src/app/contact/opengraph-image.tsx new file mode 100644 index 000000000..cf5f8fd4d --- /dev/null +++ b/src/app/contact/opengraph-image.tsx @@ -0,0 +1,18 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// See the note in docs/opengraph-image.tsx: regenerated once a day, not +// once per unfurl. +export const revalidate = 86400; +export const alt = "Contact OpenChainBench. Correct a number, correct a provider entry, or propose a benchmark."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Contact", + headline: "Tell us what is wrong.", + subline: + "A number that does not reproduce, a provider entry to correct, a benchmark worth adding, or anything else.", + }); +} diff --git a/src/app/docs/opengraph-image.tsx b/src/app/docs/opengraph-image.tsx new file mode 100644 index 000000000..938228e1b --- /dev/null +++ b/src/app/docs/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { OG_SIZE, renderHubOG } from "@/lib/og-hub-template"; + +export const runtime = "nodejs"; +// Share cards change slowly; crawlers and link unfurlers fetch them +// constantly. Without a revalidate the image is regenerated (satori, +// ~1-2 s of CPU) on every request. +export const revalidate = 86400; +export const alt = "OpenChainBench docs. MCP server, REST API, Markdown views and citation formats."; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default function OG() { + return renderHubOG({ + kicker: "Docs", + headline: "Readable without a browser.", + subline: + "One MCP server with four tools, eleven REST endpoints, every page as Markdown, and the data under CC BY 4.0.", + }); +} diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 5d5e30f01..35e517d09 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -8,8 +8,10 @@ import { withUtm } from "@/lib/utm"; export const metadata: Metadata = pageMetadata({ path: "/docs", title: "Docs", + // 254 characters was the first draft, which Google truncates at about + // 160. This says the same thing in one line a search result can show. description: - "Every machine-readable surface OpenChainBench publishes: the MCP server and its four tools, eleven REST endpoints with an OpenAPI 3.1 spec, Markdown views of the pages, llms.txt, four citation formats under CC BY 4.0, embeddable SVG badges and the feeds.", + "The MCP server and its four tools, eleven REST endpoints, every benchmark page as Markdown, and four citation formats. No key, no signup, CC BY 4.0.", }); /** From 4294bd3023b3ec54399f20a3b62d93161ee01976 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:46:30 +0200 Subject: [PATCH 016/187] /docs gets the methodology layout and a contents list (#2762) It shipped as one column of prose and bullets, which is fine for a page read top to bottom and wrong for this one: nobody reads an API index in order, they arrive mid-task looking for one endpoint. Same shell as /methodology now. The wider max-w-5xl, the large hero, numbered Section I to VIII headers, and card-soft panels instead of loose lists. The four MCP tools and the eleven endpoints are hover-ruled definition rows, the same component /methodology uses for its statistical conventions, which gives the endpoint path and its description a stable two-column relationship instead of running them together. The contents list is the one thing /methodology does not have and this page needs. Eight sections, each with an anchor, in a two-column card under the hero. Section headers carry scroll-mt-24 so an anchor does not land under the sticky header. Two things improved while moving: The Markdown section listed its five paths inline and mentioned the exclusions in a trailing clause. The paths are chips now and the exclusion is its own line, because "/rpc and /chains/{slug} are not among these" is the sentence that saves someone an afternoon. The citation section had the two Zenodo DOIs in one paragraph with the index endpoints. They are different decisions. Which DOI you cite depends on whether the reference should follow the dataset or freeze with your result; which index endpoint you point at depends on whether the page will move. Two cards, one each. Every fact is unchanged and still the probed one. --- src/app/docs/page.tsx | 471 +++++++++++++++++++++++++++++------------- 1 file changed, 326 insertions(+), 145 deletions(-) diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 35e517d09..27e6cc2f9 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -1,29 +1,28 @@ -import type { Metadata } from "next"; import Link from "next/link"; -import { SectionRule } from "@/components/section-rule"; import { pageMetadata } from "@/lib/page-metadata"; import { SITE } from "@/data/site"; import { withUtm } from "@/lib/utm"; -export const metadata: Metadata = pageMetadata({ +export const metadata: import("next").Metadata = pageMetadata({ path: "/docs", - title: "Docs", // 254 characters was the first draft, which Google truncates at about // 160. This says the same thing in one line a search result can show. description: "The MCP server and its four tools, eleven REST endpoints, every benchmark page as Markdown, and four citation formats. No key, no signup, CC BY 4.0.", + title: "Docs", }); /** - * The index of everything a machine can read here. + * The index of everything a machine can read here, laid out like + * /methodology: one numbered section per surface, cards rather than + * prose, and a contents list at the top because this page is reference + * material people arrive at mid-task rather than something read in order. * * Nothing on this page is written from memory. Every endpoint, format, * transport and path below was probed against production while the page - * was written, and the few that are not reachable are either absent or - * stated as such. The two facts most likely to rot are the tool list and - * the endpoint table, so both name where the live truth is: tools/list on - * the server itself, and /api/openapi.json, which is generated rather - * than hand-written. + * was written. The two facts most likely to rot say where the live truth + * is: tools/list on the server itself, and /api/openapi.json, which is + * generated from the routes rather than hand-written. * * It does NOT repeat /mcp. That page is the install flow for Claude * Desktop, Cursor and VS Code, with the deeplinks and the JSON blocks; @@ -32,6 +31,48 @@ export const metadata: Metadata = pageMetadata({ const MCP_URL = `${SITE.url}/api/mcp/mcp`; +const SECTIONS = [ + { n: "I", id: "start", label: "Where to start" }, + { n: "II", id: "mcp", label: "MCP server" }, + { n: "III", id: "rest", label: "REST API" }, + { n: "IV", id: "markdown", label: "Pages as Markdown" }, + { n: "V", id: "agents", label: "Files written for agents" }, + { n: "VI", id: "citing", label: "Citing a number" }, + { n: "VII", id: "badges", label: "Badges and embeds" }, + { n: "VIII", id: "feeds", label: "Feeds and mirrors" }, +] as const; + +const START = [ + { + n: "Using an AI assistant", + color: "var(--color-good, #6a9466)", + body: "Point it at the MCP server and the benchmarks become a tool your model can call.", + href: "/mcp", + cta: "Install instructions", + }, + { + n: "Writing code", + color: "var(--color-accent, #c97c5d)", + body: "Eleven REST endpoints, no key and no signup, described by an OpenAPI 3.1 document.", + href: "/api/openapi.json", + cta: "OpenAPI document", + }, + { + n: "Scraping or citing", + color: "var(--color-warn, #c08a3c)", + body: "Ask any benchmark page for Markdown, or take a citation record in the format your tool imports.", + href: "#citing", + cta: "Citation formats", + }, + { + n: "Checking how a number was made", + color: "var(--color-info, #5b7fa6)", + body: "Every figure is a query against a public Prometheus, and every bench links the harness that produced it.", + href: "/methodology", + cta: "Methodology", + }, +] as const; + const TOOLS = [ { name: "list_benchmarks", @@ -67,13 +108,38 @@ const ENDPOINTS = [ ] as const; /** The paths that answer Markdown. Checked individually: /rpc and - * /chains/{slug} do NOT, and saying they did would waste a reader's - * afternoon. */ + * /chains/{slug} do NOT, and saying they did would waste an afternoon. */ const MARKDOWN_PATHS = ["/benchmarks/{slug}", "/products/{slug}", "/perps", "/rwa", "/capital"]; +const AGENT_FILES = [ + { href: "/llms.txt", body: "The site in the llms.txt convention: what each section holds and when to cite it." }, + { href: "/llms-full.txt", body: "The same, expanded, with the benchmark catalogue inline." }, + { href: "/api/llm-context", body: "Every ranking and methodology note as one Markdown document." }, +] as const; + +const MIRRORS = [ + { href: "https://huggingface.co/datasets/OpenChainBench/benchmarks", label: "Hugging Face dataset", body: "The catalogue as a dataset, for notebooks and training sets." }, + { href: "https://huggingface.co/spaces/OpenChainBench/leaderboard", label: "Hugging Face Space", body: "The leaderboard as a hosted app." }, + { href: "https://github.com/ChainBench/OpenChainBench", label: "GitHub", body: "The specs, every harness, and the site itself. MIT for the code." }, +] as const; + +function SectionHeader({ number, label, id }: { number: string; label: string; id: string }) { + return ( +
    + + Section {number} + +

    {label}

    +
    + ); +} + function Code({ children }: { children: React.ReactNode }) { return ( -
    +    
           {children}
         
    ); @@ -81,144 +147,259 @@ function Code({ children }: { children: React.ReactNode }) { export default function DocsPage() { return ( -
    -
    -

    Docs

    -

    - Every surface here is readable by a machine without a browser. One MCP server, eleven REST endpoints, the pages themselves as Markdown, and the data under CC BY 4.0. +

    + {/* Hero */} +
    +

    Docs

    +

    + Every surface here is readable by a machine without a browser. One MCP + server, eleven REST endpoints, the pages themselves as Markdown, and + the data under CC BY 4.0. No key and no signup for any of it.

    - -
      -
    • - Using an AI assistant. Point it at the MCP server. Install instructions for Claude Desktop, Cursor and VS Code. -
    • -
    • - Writing code. The REST endpoints below, or the OpenAPI 3.1 document. -
    • -
    • - Scraping or citing. Ask any benchmark page for Markdown, or take the citation record in the format your tool wants. -
    • -
    • - Wondering how a number was produced. That is methodology, and every bench page links the harness that produced it. -
    • -
    - - -

    - Streamable HTTP, no auth, 60 requests per minute per IP. The mcp transport is the only one served. -

    - {MCP_URL} -

    Four tools:

    -
      - {TOOLS.map((t) => ( -
    • - {t.name} -

      {t.body}

      -
    • - ))} -
    -

    - The server answers tools/list, which is the version of this list that cannot go stale. -

    - - -

    - No key, no signup. Responses are cached at the edge, so a client that polls faster than the data moves is served from cache rather than rate-limited. -

    -
    - - - - - - - - - - {ENDPOINTS.map((e) => ( - - - - - - ))} - -
    PathReturnsWhat it is
    {e.path}{e.type}{e.body}
    -
    -

    - The OpenAPI document is generated from the routes, so it is the one to trust if this table and it ever disagree. -

    - - -

    - Send Accept: text/markdown and these paths answer with the table instead of the DOM. An agent gets the numbers without parsing HTML. -

    - {`curl -H 'Accept: text/markdown' ${SITE.url}/benchmarks/ethereum-rpc`} -

    - Served for: {MARKDOWN_PATHS.map((p, i) => ( - - {p} - {i < MARKDOWN_PATHS.length - 1 ? ", " : ""} - - ))}. Other paths answer HTML whatever the header says. -

    - - -
      -
    • - /llms.txt · the site in the llms.txt convention: what each section holds and when to cite it. -
    • -
    • - /llms-full.txt · the same, expanded, with the benchmark catalogue inline. -
    • -
    • - /api/llm-context · every ranking and methodology note as one Markdown document. -
    • -
    - - -

    - The data is CC BY 4.0. Attribution is OpenChainBench plus the benchmark page. The dataset has two Zenodo DOIs and they are not interchangeable. 10.5281/zenodo.20800311 is the concept DOI and always resolves to the newest version, which is what a living reference wants. 10.5281/zenodo.20800312 is the current version, v1.0.1-dataset, and is the one to cite when a result has to stay reproducible. -

    - {`${SITE.url}/api/cite/ethereum-rpc/bib # BibTeX + {/* Contents. This page is reference material people arrive at + mid-task, so the sections are listed before the first one. */} + + + {/* I. Where to start */} +
    + +
      + {START.map((p) => ( +
    1. + +

      {p.n}

      +

      {p.body}

      + {p.href.startsWith("#") ? ( + + {p.cta} + + ) : ( + + {p.cta} + + )} +
    2. + ))} +
    +
    + + {/* II. MCP */} +
    + +

    + Streamable HTTP, no auth, 60 requests per minute per IP. The{" "} + mcp transport is the only one served.{" "} + + Install it + {" "} + in Claude Desktop, Cursor or VS Code. +

    + {MCP_URL} +
    + {TOOLS.map((t) => ( +
    +
    + {t.name} +
    +
    {t.body}
    +
    + ))} +
    +

    + The server answers tools/list, which is the version of + this list that cannot go stale. +

    +
    + + {/* III. REST */} +
    + +

    + No key, no signup. Responses are cached at the edge, so a client that polls faster than + the data moves is served from cache rather than rate-limited. +

    +
    + {ENDPOINTS.map((e) => ( +
    +
    +

    + {e.path} +

    +

    + {e.type} +

    +
    +

    {e.body}

    +
    + ))} +
    +

    + The OpenAPI document is generated + from the routes, so it is the one to trust if this table and it ever disagree. +

    +
    + + {/* IV. Markdown */} +
    + +

    + Send Accept: text/markdown and these paths + answer with the table instead of the DOM. An agent gets the numbers without parsing HTML. +

    + {`curl -H 'Accept: text/markdown' ${SITE.url}/benchmarks/ethereum-rpc`} +
    + {MARKDOWN_PATHS.map((p) => ( + + {p} + + ))} +
    +

    + Every other path answers HTML whatever the header asks for, /rpc{" "} + and /chains/{"{slug}"} included. +

    +
    + + {/* V. Agent files */} +
    + +
    + {AGENT_FILES.map((f) => ( +
    +
    + + {f.href} + +
    +
    {f.body}
    +
    + ))} +
    +
    + + {/* VI. Citing */} +
    + +

    + The data is{" "} + + CC BY 4.0 + + . Attribution is OpenChainBench plus the benchmark page. +

    + {`${SITE.url}/api/cite/ethereum-rpc/bib # BibTeX ${SITE.url}/api/cite/ethereum-rpc/ris # RIS, for Zotero and EndNote ${SITE.url}/api/cite/ethereum-rpc/apa # APA, as plain text ${SITE.url}/api/cite/ethereum-rpc/txt # one sentence with the value and date`} -

    - A benchmark page moves. /api/citable is the index as it stands now, and /api/citable/{"{date}"} is that index as it stood on a past day, which is what a paper should point at. -

    - - -

    - An SVG badge with a live value, for a README or a status page. The badges catalog has one per benchmark and provider, and partners carries the embed terms. -

    - {`${SITE.url}/api/badge/ethereum-rpc/drpc`} - - - - - -
      -
    • - Hugging Face dataset · the catalogue as a dataset, for notebooks and training sets. -
    • -
    • - Hugging Face Space · the leaderboard as a hosted app. -
    • -
    • - GitHub · the specs, every harness, and the site itself. MIT for the code. -
    • -
    - - -

    - A broken endpoint or a number that does not reproduce is worth a report, and both have a destination on the contact page. Adding a benchmark of your own is contribute. -

    +
    +
    +

    Two DOIs, not interchangeable

    +

    + 10.5281/zenodo.20800311 is the concept + DOI and follows the newest version, which is what a living reference wants.{" "} + 10.5281/zenodo.20800312 is the current + version, v1.0.1-dataset, and is the one to cite when a result has to stay reproducible. +

    +
    +
    +

    A benchmark page moves

    +

    + + /api/citable + {" "} + is the index as it stands now.{" "} + /api/citable/{"{date}"} is that index as + it stood on a past day, which is what a paper should point at. +

    +
    +
    +
    + + {/* VII. Badges */} +
    + +

    + An SVG badge with a live value, for a README or a status page. The{" "} + badges catalog has one per benchmark and + provider, and partners carries the embed + terms. +

    + {`${SITE.url}/api/badge/ethereum-rpc/drpc`} +
    + + {/* VIII. Feeds and mirrors */} +
    + +
    +
    +

    Feeds

    +
      +
    • + /rss.xml and{" "} + /feed.json · + new benchmarks and reports. +
    • +
    • + /sitemap.xml · + every indexable page with its last-modified date. +
    • +
    +
    +
    +

    The same data elsewhere

    + +
    +
    +
    + +
    +

    If something here is wrong

    +

    + A broken endpoint or a number that does not reproduce is worth a report, and both have a + destination on the contact page. Adding a + benchmark of your own is contribute. +

    +
    ); } From ca0bc42c40255ad3f3635865b84f9f266106bfcd Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:47:23 +0200 Subject: [PATCH 017/187] the app directory needs a privacy policy and terms, and both were 404 (#2759) * the app directory needs a privacy policy and terms, and both were 404 Listing the MCP server at /api/mcp/mcp in the ChatGPT app directory requires four reachable URLs in the submission form: website, support, privacy policy and terms of service. The first two exist. The other two answered 404 on production, so the submission could not be filled in at all. The privacy policy is read off the code rather than drafted from a template, because overstating what we collect is as wrong as understating it. Every claim traces to a line: autocapture is off and person profiles are disabled in posthog-provider.tsx and nothing ever calls identify; analytics are proxied through our own origin so the browser never contacts the analytics host; the server-side id in analytics-server.ts is a daily hash of the user agent with no IP stored; rate-limit buckets live in an in-memory Map that never reaches disk. If any of those change this page changes in the same commit. The terms carry two clauses that are not boilerplate. "Not financial advice, we never execute transactions, never send us a key" is what the three negative test cases the review requires will point at. The conflict-of-interest disclosure repeats what publisher.json and /partners already say, and all three must stay identical. The domain challenge route is the third required piece: verification fetches a token as plain text from /.well-known/openai-apps-challenge and the body must be the token alone. The token stays in an env var rather than the repo, because committing it would let anyone prove ownership of this domain for their own submission; until it is set the route 404s, which is the honest state. force-dynamic so rotating it needs no rebuild. Both pages are in the sitemap and linked from the footer: neither is reachable from the rail, and a reviewer follows those URLs. * the MCP server published a thousandfold error and 35k tokens of catalogue Three things, all found by calling the live endpoint rather than by reading the code. The values were wrong. Latency benches declaring unit "s" store milliseconds by the fmtUnit convention, and this endpoint published the stored number beside the declared unit: {"value":443.494,"unit":"s"} for a head lag the site and /api/stat both render as 0.44 s. An agent citing value and unit together was off by a thousand, under provider names, on three benches including number 001. valueInDeclaredUnit already existed for exactly this reason, its docstring names this same bench, and /api/stat already called it; the MCP server never did. Every value crossing the boundary now goes through it, and rankings carry both the stored `ms` and a `value` in the declared unit so either read is right. list_benchmarks returned the catalogue. All 229 live benches in one response, 142,555 characters, about 35,638 tokens, for a question as broad as "what do you measure". A model handed that spends its context on JSON instead of on the answer. It is now compact, capped and filterable: 25 rows of nine short fields, about 2,400 tokens, 94% less, and it says how many it held back. The tools were named for the REST surface they mirrored, not for the questions people ask, and a client routes on the description text. Nobody thinks "list benchmarks"; they ask which Solana RPC is fastest, or whether Alchemy beats QuickNode. So: search_benchmarks takes their words and ranks the catalogue against them, including by the provider names a bench measures, so "is Helius faster than Triton" finds the Solana bench without anyone knowing its slug. compare_providers takes the names they said and returns a provider it does not measure as `missing` rather than as a loser, because those are different answers. recommend_provider takes the goal they stated and returns the caveat with it, since "read p99 not p50 for a bot" is usually worth more than the ranking. query_prom is gone. Behind its allowlist it was safe enough for a developer tool, but no end user asks for a PromQL passthrough, it was attack surface with no reader, and every extra tool dilutes the routing the useful ones depend on. Its allowlist and validator went with it. Shaping lives in src/lib/mcp-tools.ts so it can be tested without a server: 13 tests cover the unit conversion in both directions, provider-name search, the cap, the longest-phrase use-case match, and the missing-vs-loser rule. Read-only annotations and structuredContent on every tool. * the resource path carried the same unit bug the tools did Found by auditing the diff rather than by a test. I converted every value on the tool path and left the one on the resource path, which serves the same benchmark to the same clients under the same field names: an agent pinning openchainbench://benchmark/aggregator-head-lag still read 443.494 next to unit "s". Both its value and its leader now go through valueInDeclaredUnit, and its rankings carry a declared-unit `value` beside the stored `ms` like the tool response does. Nine conversions in the file now, one for every raw read. --- .../openai-apps-challenge/route.ts | 47 ++ src/app/api/mcp/[transport]/route.ts | 605 +++++++++--------- src/app/privacy/page.tsx | 140 ++++ src/app/terms/page.tsx | 137 ++++ src/components/site-footer.tsx | 19 + src/lib/mcp-tools.test.ts | 147 +++++ src/lib/mcp-tools.ts | 304 +++++++++ src/lib/sitemap-builder.ts | 5 + 8 files changed, 1092 insertions(+), 312 deletions(-) create mode 100644 src/app/.well-known/openai-apps-challenge/route.ts create mode 100644 src/app/privacy/page.tsx create mode 100644 src/app/terms/page.tsx create mode 100644 src/lib/mcp-tools.test.ts create mode 100644 src/lib/mcp-tools.ts diff --git a/src/app/.well-known/openai-apps-challenge/route.ts b/src/app/.well-known/openai-apps-challenge/route.ts new file mode 100644 index 000000000..d6a2fe1e6 --- /dev/null +++ b/src/app/.well-known/openai-apps-challenge/route.ts @@ -0,0 +1,47 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Domain verification for the ChatGPT app directory. + * + * Submitting the MCP server at /api/mcp/mcp requires proving we own the + * hostname serving it: OpenAI issues a token in the submission dashboard and + * fetches it back as plain text from + * + * https://openchainbench.com/.well-known/openai-apps-challenge + * + * The rule the docs are strict about is that the response body must be the + * token and nothing else. No JSON wrapper, no trailing newline we did not + * intend, no HTML error page. So this route returns the raw string with an + * explicit text/plain type rather than going through any of the site's + * response helpers. + * + * The token lives in OPENAI_APPS_CHALLENGE (a Vercel production env var) and + * not in the repository: it is a per-submission secret, publishing it in git + * would let anyone else prove ownership of this domain for their own + * submission. Until it is set the route answers 404, which is the honest + * state (no challenge outstanding) and is indistinguishable to a crawler + * from the route not existing. + * + * `force-dynamic` because the value is read at request time: rotating the + * env var must take effect without a rebuild, and a cached empty body during + * a verification window would fail the check for no visible reason. + */ +export function GET(): Response { + const token = process.env.OPENAI_APPS_CHALLENGE?.trim(); + if (!token) { + return new Response("Not found\n", { + status: 404, + headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, + }); + } + return new Response(token, { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + // Verification reads this directly; nothing should index it. + "x-robots-tag": "noindex", + }, + }); +} diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index 1d2bd3044..5ebe2826c 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -17,258 +17,172 @@ import { } from "@/lib/citation"; import { answerOneLine, loadRenderedAnswers } from "@/lib/answers-rendered"; import { benchMarkdown } from "@/lib/markdown-views"; -import { Prometheus } from "@/lib/prometheus"; +import { valueInDeclaredUnit } from "@/lib/format"; +import { + compactRow, + compareOnBenchmark, + matchUseCase, + searchBenchmarks, +} from "@/lib/mcp-tools"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; /** * MCP server. Exposes OpenChainBench data to any MCP-capable agent - * (Claude Desktop, ChatGPT custom tools, generic MCP clients) via four - * tools that mirror the public REST surface. Streamable-HTTP only - the - * SSE transport requires Redis which we don't run. + * (Claude Desktop, ChatGPT apps, generic MCP clients). Streamable-HTTP only + * - the SSE transport requires Redis which we don't run. * * Connect at: * https://openchainbench.com/api/mcp/mcp + * + * The tools are shaped by the questions people ask, not by the REST surface + * they used to mirror, because a client routes on the description text: a + * tool called "list_benchmarks" is never going to match "which Solana RPC + * should I use". So `search_benchmarks` takes the user's own words, + * `compare_providers` takes the names they said, and `recommend_provider` + * takes the goal they stated. `get_benchmark` remains the way to open one + * bench in full once you know its slug. + * + * Two deliberate removals: + * - `query_prom` took raw PromQL. Behind an allowlist it was safe enough + * for a developer tool, but no end user asks for a PromQL passthrough, + * it was attack surface with no reader, and every extra tool dilutes the + * routing the useful ones depend on. Agents that want arbitrary series + * can still query Prometheus directly; the site does not proxy it. + * - `list_benchmarks` no longer returns the catalogue. It served all 229 + * live benches in one response, 142,534 characters, about 35,600 tokens, + * for questions as broad as "what do you measure". It is now compact, + * capped and filterable. + * + * Values cross this boundary in the unit the bench declares, via + * `valueInDeclaredUnit`. Latency benches declaring `unit: "s"` store + * milliseconds internally, and this endpoint used to publish the stored + * number: {"value":443.494,"unit":"s"} for a head lag the site and + * /api/stat both render as 0.44 s. Same bug the REST route fixed earlier; + * it was never carried across. */ // Maximum POST body. The MCP handler doesn't enforce a per-request cap; // we do it before letting the package parse the body. const MAX_BODY_BYTES = 64 * 1024; -// query_prom allowlist: every metric-like identifier in a query must -// match one of these prefixes. Closes the wallet_balance / up / scrape -// / instance-leak class of exfil attacks via the public MCP endpoint. -// Derived from the metric names declared by current benchmark YAMLs. -const QUERY_PROM_ALLOWED_METRIC_PREFIXES = [ - // Aggregator latency / head-lag - "head_lag_seconds", - // Bridge benches (bridge-fee + bridge-quote-latency) - "bridge_quote_latency_ms", - "bridge_cost", - "bridge_fees", - "bridge_fix_fee", - "bridge_gas", - "bridge_output", - "bridge_estimated_time", - "bridge_quote_success", - // Bridge execution bench (#261): real on-chain settlement + realized cost - "bridge_execution_latency_ms", - "bridge_e2e_latency_ms", - "bridge_success_total", - "bridge_reverts_total", - "bridge_refunds_total", - "bridge_stuck_total", - "bridge_refund_latency_ms", - "bridge_realized_output_usd", - "bridge_quote_slippage_usd", - "bridge_exec_gas_usd", - // L1 finality + L2 block time - "l1_finality_", - "l2_block_time_", - // Metadata + network + wallet coverage - "metadata_coverage_", - "metadata_api_latency_", - "networks_supported", - "network_coverage_", - "wallet_labels_", - // PM freshness bench - "pm_", - // EVM swap quote latency bench (#033) - "evm_swap_quote_", - // Perp fees + funding + venue KPIs + execution scanner + buyback + oracle + validator yield - "perp_fees_", - "perp_funding_", - "perp_venue_", - "perp_execution_", - // Perp liquidation rate bench (bench 208) - "perp_liq_", - "perp_realized_vol_", - "ocb_buyback_", - "ocb_oracle_", - "ocb_validator_", - "ocb_chain_", - // Gas oracle prediction accuracy - "gas_error_", - "gas_predicted_", - "gas_realized_", - "gas_oracle_", - // Stablecoin peg (+ usdt-anchored variant) - "peg_", - // Solana TX landing (observational + active) - "solana_landing_", - // Public RPC capabilities - "rpc_latency_", - "rpc_call_total", - "rpc_health", - "rpc_archive_depth_supported", - // Hyperliquid frontends quality bench (bench № 030) - "hl_frontend_", - // Capital and valuation (benches 265, 273, 274, 275, chain-kpis): P/F, - // P/S, mcap, FDV, float, fees, revenue, TVL, bridged TVL, stablecoin - // flows, chain fees. Opened 2026-09-25 for third-party analysis. - "perp_protocol_", - "protocol_", - "chain_", -]; - -// PromQL identifiers that are NOT metric names - built-in functions, -// aggregators, modifiers, plus the label names that appear bare in -// selectors. Mirrors the set in src/lib/prometheus.ts plus the labels. -const PROMQL_RESERVED_IDENTS = new Set([ - "sum", "avg", "max", "min", "count", "count_values", "stddev", "stdvar", - "topk", "bottomk", "group", "quantile", - "on", "ignoring", "group_left", "group_right", "by", "without", "bool", - "and", "or", "unless", "offset", - "rate", "irate", "increase", "delta", "idelta", "deriv", "predict_linear", - "quantile_over_time", "avg_over_time", "max_over_time", "min_over_time", - "sum_over_time", "count_over_time", "stddev_over_time", "stdvar_over_time", - "last_over_time", "present_over_time", "mad_over_time", - "changes", "resets", - "histogram_quantile", "histogram_sum", "histogram_count", "histogram_avg", - "histogram_fraction", "histogram_stddev", "histogram_stdvar", - "label_replace", "label_join", - "abs", "floor", "ceil", "round", "exp", "ln", "log2", "log10", "sqrt", - "clamp_max", "clamp_min", "clamp", "sgn", "sort", "sort_desc", - "atan", "atanh", "acos", "acosh", "asin", "asinh", - "cos", "cosh", "sin", "sinh", "tan", "tanh", "deg", "rad", - "time", "timestamp", "scalar", "vector", "minute", "hour", - "day_of_month", "day_of_week", "day_of_year", "days_in_month", - "month", "year", - "absent", "absent_over_time", "present", "pi", - // PromQL @-modifier anchors (Prom 2.26+): `metric @ start()` / `@ end()`. - "start", "end", "step", - // Label names that appear as bare identifiers inside selectors / `by(...)`. - "le", "chain", "region", "provider", "bridge", "aggregator", "venue", - "exchange", "asset", "from_chain", "to_chain", "from_token", "to_token", - "amount_usd", "side", "type", "error_type", - // Labels of the capital and valuation families opened 2026-09-25 - // (protocol_*, perp_protocol_*, chain_*, hl_*): without them every - // per-protocol selector such as perp_protocol_pf_ratio{protocol="x"} - // was refused as an unlisted metric name. - "protocol", "name", "category", "symbol", "source", "origin", "window", - "threshold", "bucket", "coin", "builder", "dex", "app", "tier", -]); - -// Whitespace + non-ASCII spacing variants stripped before pattern checks -// so a NBSP / ZWSP between __name__ and the operator can't slip the rules. -const NON_ASCII_WS = new RegExp( - "[\\s\\u00a0\\u1680\\u2000-\\u200b\\u202f\\u205f\\u3000\\ufeff]+", - "g", -); - -/** Decide whether a public query_prom request is allowed. Two passes: - * - Enumeration patterns that would walk the metric catalog / fingerprint - * topology, ignoring case-sensitivity tricks via Unicode whitespace. - * - Allowlist of metric-name prefixes - every metric-like identifier in - * the query must match a published benchmark namespace, otherwise the - * query is refused. This is what turns the public MCP from a passthrough - * into a sandbox bound to the data the site already serves. */ -function isQueryAllowed(q: string): { ok: true } | { ok: false; reason: string } { - // Strip PromQL `#` comments first so a comment like `# wallet_balance` - // doesn't tip the allowlist. PromQL comments run to end-of-line. - const noComments = q.replace(/#[^\n]*/g, ""); - // Strip string literals so quoted label VALUES (`aggregator="mobula"`) - // never get scanned as identifiers - they're attacker-controlled text, - // but PromQL escapes their content via Prom's parser, not our regex. - const stripped = noComments.replace(/"(?:\\.|[^"\\])*"/g, '""'); - // Whitespace-normalised form. Used for every pattern check so a NBSP - // between operator and operand can't slip a rule. - const c = stripped.replace(NON_ASCII_WS, ""); - - // Pattern blocks - all operate on `c`, the comment+string-stripped, - // whitespace-normalised form. - if (/__name__[=!]~/.test(c)) return { ok: false, reason: "name_regex_blocked" }; - if (c.includes("{}")) return { ok: false, reason: "empty_selector_blocked" }; - if (/__name__="(\.\+|\.\*|\(.+\))"/.test(c)) return { ok: false, reason: "name_catchall_blocked" }; - if (/(__name__|__address__|job|instance|host)(!?~|!="")/.test(c)) { - return { ok: false, reason: "label_enum_blocked" }; - } - // Aggregation by (instance|host|__name__|__address__|job) reveals - // topology labels we don't publish. `by (le)` is fine - it's how - // every histogram_quantile query in our specs is shaped. - if ( - /\b(group|count|sum|avg|min|max|topk|bottomk|stddev|stdvar|quantile)by\((__name__|__address__|job|instance|host)\b/.test( - c, - ) - ) { - return { ok: false, reason: "topology_aggregation_blocked" }; - } - if (/\bcount_values\b/.test(c)) return { ok: false, reason: "count_values_blocked" }; - - // Metric-name allowlist - every identifier-like token outside strings, - // comments, AND range-vector durations must match a published benchmark - // namespace. Strip `[5m]` / `[24h]` / `[7d]` etc. so the `h`/`m`/`d`/`s` - // suffix letters aren't scanned as identifiers. - const noDurations = stripped.replace(/\[\s*\d+\s*(?:ms|s|m|h|d|w|y)\s*\]/g, "[]"); - let sawAllowed = false; - const idents = noDurations.match(/[a-zA-Z_:][a-zA-Z0-9_:]*/g) ?? []; - for (const id of idents) { - if (PROMQL_RESERVED_IDENTS.has(id)) continue; - if (/^\d/.test(id)) continue; - if (QUERY_PROM_ALLOWED_METRIC_PREFIXES.some((p) => id.startsWith(p))) { - sawAllowed = true; - continue; - } - return { ok: false, reason: `metric_not_allowlisted:${id}` }; - } - if (!sawAllowed) return { ok: false, reason: "no_benchmark_metric_referenced" }; - return { ok: true }; -} - const mcpHandler = createMcpHandler( (server) => { server.registerTool( "list_benchmarks", { - title: "List OpenChainBench benchmarks", + title: "Browse the benchmark catalogue", description: [ - "Returns a flat index of every published OpenChainBench benchmark with its", - "current headline value, leader, category, units, and citation URL.", + "A compact index of what OpenChainBench measures. One short row per", + "benchmark: slug, title, category, the metric, the current value and", + "who leads it.", "", - "Call this first when the user asks a discovery question like", - "\"what benchmarks does OpenChainBench have?\" or \"compare crypto aggregators\".", - "Then use `get_benchmark` for the specific slug(s) the answer needs.", + "Use when the user asks what is measured at all (\"what does", + "OpenChainBench cover?\", \"do you track bridges?\"). When they ask a", + "question about a provider or a chain, use `search_benchmarks` instead:", + "it ranks the catalogue against their words and costs far less to read.", "", - "Returns one line per bench:", - " { slug, title, category, metric, unit, value, leader, headline, url, asOf }", + "The catalogue holds over 200 benchmarks, so this is capped and", + "filterable rather than exhaustive. Narrow with `category`, then open", + "the one you need with `get_benchmark`.", "", - "Drafts are filtered out: only live benchmarks appear.", + "Categories: RPCs, Trading, Bridges, Blockchains, Aggregators, RWA, NFT APIs.", ].join("\n"), - inputSchema: {}, + inputSchema: { + category: z + .string() + .max(40) + .optional() + .describe("Only this category: RPCs, Trading, Bridges, Blockchains, Aggregators, RWA or NFT APIs."), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("How many rows to return, 1 to 50. Default 25."), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, }, - async () => { + async ({ category, limit }) => { const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); - const rows = benches.map((b) => { - const insufficient = isInsufficient(b); - const top = insufficient ? null : leader(b); - const status: "live" | "draft" | "insufficient" = insufficient - ? "insufficient" - : b.status; - return { - slug: b.slug, - title: b.title, - category: b.category, - metric: b.metric, - unit: b.unit, - status, - value: insufficient ? null : fieldValue(b), - leader: top, - headline: headlineSentence(b), - url: `${SITE.url}/benchmarks/${b.slug}`, - asOf: citableAsOf(b), - // Chain RPC pages rank two access cohorts apart (public, no - // key; private, API key): each with its own leader and URL. - ...(() => { - const cohorts = cohortSummaries(b, SITE.url); - return cohorts.length > 0 - ? { cohorts: cohorts.map((c) => ({ tier: c.tier, label: c.label, leader: c.leader, url: c.url })) } - : {}; - })(), - }; + const cat = category?.trim().toLowerCase(); + const pool = cat ? benches.filter((b) => b.category.toLowerCase() === cat) : benches; + const capped = pool.slice(0, Math.min(Math.max(limit ?? 25, 1), 50)); + const rows = capped.map((b) => compactRow(b, SITE.url)); + const payload = { + returned: rows.length, + totalMatching: pool.length, + totalLive: benches.length, + ...(cat ? { category } : {}), + note: + pool.length > rows.length + ? `Showing ${rows.length} of ${pool.length}. Narrow with category, or use search_benchmarks with the user's own words.` + : undefined, + benchmarks: rows, + }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; + }, + ); + + server.registerTool( + "search_benchmarks", + { + title: "Find the benchmark that answers a question", + description: [ + "Ranks the benchmark catalogue against a question in the user's own", + "words and returns the best few matches.", + "", + "Use this when somebody asks which provider, chain or service is", + "fastest, cheapest, most reliable or best for something. Pass their", + "phrasing through: provider names, chain names and goals all match.", + "", + " • \"which Solana RPC is fastest\" -> search_benchmarks({ query: \"solana rpc\" })", + " • \"Alchemy or QuickNode on Base?\" -> search_benchmarks({ query: \"alchemy quicknode base\" })", + " • \"cheapest way to bridge to Arbitrum\" -> search_benchmarks({ query: \"bridge arbitrum\" })", + "", + "Returns compact rows. Open the one you want with `get_benchmark` for", + "the full ranking and a citation line. An empty result means nothing", + "is measured for that question; say so rather than guessing.", + ].join("\n"), + inputSchema: { + query: z + .string() + .min(1) + .max(200) + .describe("The user's question or keywords, verbatim. Provider and chain names work well."), + category: z + .string() + .max(40) + .optional() + .describe("Optional category filter: RPCs, Trading, Bridges, Blockchains, Aggregators, RWA, NFT APIs."), + limit: z.number().int().min(1).max(25).optional().describe("How many matches, 1 to 25. Default 8."), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ query, category, limit }) => { + const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); + const matches = searchBenchmarks(benches, { + query, + category, + limit: limit ?? 8, + siteUrl: SITE.url, }); + const payload = { + query, + count: matches.length, + matches, + ...(matches.length === 0 + ? { note: "Nothing in the catalogue matches. OpenChainBench may not measure this; do not infer a winner." } + : {}), + }; return { - content: [{ type: "text", text: JSON.stringify({ count: rows.length, benchmarks: rows }, null, 2) }], + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, }; }, ); @@ -329,6 +243,7 @@ const mcpHandler = createMcpHandler( .optional() .describe("Optional access cohort on chain RPC benchmarks: 'public' (default, free no-key endpoints) or 'keyed' (private, API-key providers). Only honored when the bench declares tier dimensions."), }, + annotations: { readOnlyHint: true, openWorldHint: true }, }, async ({ slug, chain, region, tier }) => { const aggregate = await getBenchmark(slug); @@ -357,6 +272,9 @@ const mcpHandler = createMcpHandler( const status: "live" | "draft" | "insufficient" = insufficient ? "insufficient" : b.status; + // `ms` is the stored field and keeps its name; `value` beside it is + // the same number in the bench's declared unit, so a caller reading + // either one is right. const rankings = insufficient ? b.results.map((r) => ({ name: r.name, @@ -368,16 +286,22 @@ const mcpHandler = createMcpHandler( name: r.name, slug: r.slug, ms: r.ms, + value: r.ms.p50 == null ? null : valueInDeclaredUnit(r.ms.p50, b.unit), successRate: r.successRate, })); + // Latency benches declaring unit "s" store milliseconds (the fmtUnit + // convention in format.ts). /api/stat has converted on the way out + // since that bug was found there; this endpoint never did, and served + // {"value":443.494,"unit":"s"} for a head lag the site renders 0.44 s. + const raw = insufficient ? null : fieldValue(b); const payload = { slug: b.slug, title: b.title, metric: b.metric, unit: b.unit, status, - value: insufficient ? null : fieldValue(b), - leader: top, + value: raw == null ? null : valueInDeclaredUnit(raw, b.unit), + leader: top == null ? null : { ...top, value: valueInDeclaredUnit(top.value, b.unit) }, rankings, sparkline: insufficient ? [] : sparklineFor(b, top?.slug), headline: headlineSentence(b), @@ -389,107 +313,156 @@ const mcpHandler = createMcpHandler( source: b.source, ...(cohortSummaries(b, SITE.url).length > 0 ? { cohorts: cohortSummaries(b, SITE.url) } : {}), }; - return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; }, ); server.registerTool( - "query_prom", + "compare_providers", { - title: "Run a PromQL query (scoped to benchmark metric namespaces)", + title: "Compare named providers head to head", description: [ - "Direct PromQL passthrough for advanced questions that don't map cleanly", - "to `list_benchmarks` / `get_benchmark`, e.g. \"what was Mobula's p50", - "head-lag yesterday at 14:00 UTC\" or \"plot bridge fees over the last hour\".", + "Puts two or more named providers side by side on one benchmark, with", + "each one's rank, measured value and success rate.", "", - "Prefer the higher-level tools first; reach for this when you need:", - " • a custom time window (instant query at a specific point, or range)", - " • a derived metric (rates, ratios, deltas)", - " • a histogram bucket aggregation across chains/regions", + "Use when the user names the candidates themselves: \"Alchemy or", + "QuickNode?\", \"is Helius faster than Triton for Solana?\", \"compare", + "Across and Stargate on fees\".", "", - "Allowed metric namespaces (one prefix per OCB bench family):", - " head_lag_seconds (aggregator latency)", - " bridge_quote_latency_ms*, bridge_cost*, bridge_fees*, bridge_fix_fee*,", - " bridge_execution_latency_ms*, bridge_e2e_latency_ms*, bridge_success_total,", - " bridge_reverts_total, bridge_refunds_total, bridge_stuck_total,", - " bridge_refund_latency_ms*, bridge_realized_output_usd, bridge_quote_slippage_usd, bridge_exec_gas_usd,", - " bridge_gas*, bridge_output*, bridge_estimated_time*, bridge_quote_success", - " l1_finality_*, l2_block_time_*", - " metadata_coverage_*, metadata_api_latency_*, network_coverage_*,", - " networks_supported, wallet_labels_*", - " perp_fees_*, perp_funding_*, perp_venue_*, perp_execution_*,", - " perp_liq_*, perp_realized_vol_*,", - " ocb_buyback_*, ocb_oracle_*, ocb_validator_*, ocb_chain_*", - " gas_error_*, gas_predicted_*, gas_realized_*, gas_oracle_*", - " peg_* (stablecoin peg, both variants)", - " solana_landing_* (TX landing observational + active)", - " rpc_latency_*, rpc_call_total, rpc_health, rpc_archive_depth_supported", - " perp_protocol_*, protocol_* (P/F, P/S, mcap, FDV, float, fees, revenue; benches 265, 274)", - " chain_* (TVL, bridged TVL, value secured, stablecoin flows, native mcap, chain fees; benches 273, 275)", - "Queries referencing other metrics (operational/internal ones like `up`,", - "`scrape_*`, `process_*`, `go_*`, `wallet_balance_*` or any label-", - "enumeration shape) are refused with `{error, reason}`.", - "", - "Example: instant p50 over 1h for Mobula head-lag on Base:", - " query_prom({", - " query: \"quantile_over_time(0.5, head_lag_seconds{aggregator=\\\"mobula\\\",chain=\\\"base\\\"}[1h]) * 1000\"", - " })", - "", - "Example: 7-day sparkline of average bridge fees:", - " query_prom({", - " query: \"avg_over_time(bridge_fees_percent[1d])\",", - " windowSec: 604800,", - " steps: 168", - " })", - "", - "Returns: `{ query, value }` for instant queries, `{ query, windowSec, series }` for range.", + "Find the benchmark slug with `search_benchmarks` first if you do not", + "already have it. Providers the benchmark does not measure come back", + "in `missing`: say they are not measured rather than implying they", + "ranked badly. A provider measured but below the ranking floor comes", + "back with rank null, which is also not a loss.", ].join("\n"), inputSchema: { - query: z + benchmark: z .string() - .min(1) - .max(2000) - .describe("PromQL expression referencing published benchmark metric prefixes only. Function names, label keys, and quoted label values are fine; bare metric names must be allowlisted."), - windowSec: z - .number() - .int() - .positive() - .max(7_776_000) - .optional() - .describe("If set, run a range query over the last N seconds (max 90 days = 7776000). Omit for an instant query."), - steps: z.number().int().min(2).max(360).optional().describe("Number of samples for a range query (2 to 360). Default 60. Step duration = windowSec / steps."), + .regex(/^[a-z0-9][a-z0-9-]{0,79}$/) + .describe("Benchmark slug, e.g. 'solana-rpc' or 'bridge-fee'. Get it from search_benchmarks."), + providers: z + .array(z.string().min(1).max(60)) + .min(2) + .max(10) + .describe("Two to ten provider names or slugs, as the user said them, e.g. ['Alchemy', 'QuickNode']."), + chain: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/).optional().describe("Optional chain slice when the bench declares chains."), + region: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/).optional().describe("Optional region slice when the bench declares regions."), }, + annotations: { readOnlyHint: true, openWorldHint: true }, }, - async ({ query, windowSec, steps }) => { - const verdict = isQueryAllowed(query); - if (!verdict.ok) { - return { - content: [{ type: "text", text: JSON.stringify({ error: "query_refused", reason: verdict.reason }) }], - isError: true, - }; - } - const url = process.env.PROMETHEUS_URL; - if (!url) { - return { - content: [{ type: "text", text: JSON.stringify({ error: "prometheus_unconfigured" }) }], - isError: true, - }; - } - const prom = new Prometheus(url); - try { - if (windowSec) { - const series = await prom.series(query, windowSec, steps ?? 60); - return { content: [{ type: "text", text: JSON.stringify({ query, windowSec, series }, null, 2) }] }; - } - const v = await prom.scalar(query); - return { content: [{ type: "text", text: JSON.stringify({ query, value: v }) }] }; - } catch (err) { - console.error("[mcp:query_prom] upstream error", err); + async ({ benchmark, providers, chain, region }) => { + const b = await getBenchmark(benchmark, { chain, region }); + if (!b || b.editorialStatus !== "live") { + const payload = { error: "unknown_slug", slug: benchmark }; return { - content: [{ type: "text", text: JSON.stringify({ error: "upstream_error" }) }], + content: [{ type: "text", text: JSON.stringify(payload) }], + structuredContent: payload, isError: true, }; } + const comparison = compareOnBenchmark(b, providers, SITE.url); + const payload = { + ...comparison, + title: b.title, + headline: headlineSentence(b), + quote: citationQuote(b, SITE.url), + lowerIsBetter: !b.higherIsBetter, + }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; + }, + ); + + server.registerTool( + "recommend_provider", + { + title: "Recommend a provider for a stated use case", + description: [ + "Answers \"which should I use for X\" by picking the benchmarks that", + "measure X and reporting who currently leads them.", + "", + "Pass the user's goal in their own words: \"a Solana trading bot\", \"an", + "indexer backfilling Base\", \"a wallet that needs price data\", \"bridging", + "to Arbitrum\".", + "", + "This returns measurements and the caveat that goes with them, not an", + "endorsement. A leader on one benchmark is the leader of that one", + "measurement over its stated window. Where the use case has a known", + "caveat (a bot should read p99 rather than p50, an indexer is bound by", + "archive depth) it comes back in `guidance`; pass it on, it is usually", + "more useful than the ranking itself.", + ].join("\n"), + inputSchema: { + use_case: z + .string() + .min(2) + .max(200) + .describe("What the user is building or doing, in their words. e.g. 'solana trading bot', 'indexer', 'price feed for a wallet'."), + chain: z + .string() + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/) + .optional() + .describe("Chain they are building on, e.g. 'solana', 'base', 'arbitrum'. Narrows the benchmarks considerably."), + region: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/).optional().describe("Optional region, e.g. 'eu-west', when latency from a location matters."), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ use_case, chain, region }) => { + const benches = (await getBenchmarks()).filter((b) => b.editorialStatus === "live"); + const uc = matchUseCase(use_case); + // The chain is the strongest signal when present, so it leads the + // query; the use case words follow and break ties. + const query = [chain ?? "", use_case].join(" ").trim(); + const matches = searchBenchmarks(benches, { + query, + category: uc?.category, + limit: 5, + siteUrl: SITE.url, + }); + + const detailed = await Promise.all( + matches.slice(0, 3).map(async (m) => { + const full = await getBenchmark(m.slug, { chain, region }); + if (!full || full.editorialStatus !== "live") return null; + const top = isInsufficient(full) ? null : leader(full); + return { + benchmark: full.slug, + title: full.title, + metric: full.metric, + unit: full.unit, + leader: top == null ? null : { ...top, value: valueInDeclaredUnit(top.value, full.unit) }, + headline: headlineSentence(full), + quote: citationQuote(full, SITE.url), + url: `${SITE.url}${benchPath(full)}`, + asOf: citableAsOf(full), + }; + }), + ); + + const payload = { + useCase: use_case, + ...(chain ? { chain } : {}), + recognisedAs: uc?.id ?? null, + guidance: + uc?.why ?? + "No specific guidance for this use case; the benchmarks below are the closest matches by wording.", + caveat: + "These are measurements over a stated window, not endorsements. Check the window and the sample size on the page before relying on a ranking.", + benchmarks: detailed.filter((d) => d !== null), + alsoRelevant: matches.slice(3), + ...(matches.length === 0 + ? { note: "Nothing in the catalogue matches this use case; do not infer a recommendation." } + : {}), + }; + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; }, ); @@ -520,6 +493,7 @@ const mcpHandler = createMcpHandler( .optional() .describe("Optional benchmark slug filter: return only the answers built on that bench."), }, + annotations: { readOnlyHint: true, openWorldHint: true }, }, async ({ benchmark }) => { const all = await loadRenderedAnswers(); @@ -532,8 +506,10 @@ const mcpHandler = createMcpHandler( url: a.url, benchmarkUrl: `${SITE.url}/benchmarks/${a.benchmark}`, })); + const payload = { count: rows.length, answers: rows }; return { - content: [{ type: "text", text: JSON.stringify({ count: rows.length, answers: rows }, null, 2) }], + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, }; }, ); @@ -608,14 +584,18 @@ const mcpHandler = createMcpHandler( const status: "live" | "draft" | "insufficient" = insufficient ? "insufficient" : b.status; + // Same declared-unit rule as the tools above: a unit "s" bench stores + // milliseconds, and this resource serves the same fields to the same + // clients, so it cannot publish the stored number either. + const rawValue = insufficient ? null : fieldValue(b); const payload = { slug: b.slug, title: b.title, metric: b.metric, unit: b.unit, status, - value: insufficient ? null : fieldValue(b), - leader: top, + value: rawValue == null ? null : valueInDeclaredUnit(rawValue, b.unit), + leader: top == null ? null : { ...top, value: valueInDeclaredUnit(top.value, b.unit) }, rankings: insufficient ? b.results.map((r) => ({ name: r.name, @@ -628,6 +608,7 @@ const mcpHandler = createMcpHandler( name: r.name, slug: r.slug, ms: r.ms, + value: r.ms.p50 == null ? null : valueInDeclaredUnit(r.ms.p50, b.unit), successRate: r.successRate, sampleSize: r.sampleSize, })), diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 000000000..89a10c29a --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,140 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { SectionRule } from "@/components/section-rule"; +import { SITE } from "@/data/site"; +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata: Metadata = pageMetadata({ + path: "/privacy", + title: "Privacy", + description: + "What OpenChainBench collects: anonymous analytics, no accounts, no personal data, no advertising. Written from the code that runs the site.", +}); + +/** + * Privacy policy. + * + * Required to list the MCP server in the ChatGPT app directory (a Privacy + * Policy URL is a mandatory submission field), and it did not exist: both + * /privacy and /terms answered 404 on production. + * + * Every claim below is read off the code rather than drafted from a + * template, because a policy that overstates what we collect is as wrong as + * one that understates it: + * - `src/components/posthog-provider.tsx:57-64` sets `autocapture: false`, + * `capture_pageview: false` (pageviews are sent manually per route) and + * `person_profiles: "identified_only"`. Nothing in the codebase ever + * calls `posthog.identify`, so no person profile is ever created. + * - `api_host: "/ingest"` proxies analytics through our own origin, so the + * browser never talks to a third-party analytics host directly. + * - `src/lib/analytics-server.ts:72` derives the server-side id as + * sha256(user-agent + UTC day) and sets `$process_person_profile: false`; + * no IP is stored. + * - `src/lib/rate-limit.ts:21` keeps rate-limit buckets in an in-memory + * Map keyed by IP. It is process-local, never written to disk, and lost + * on every deploy. + * If any of those change, this page changes in the same commit. + */ +export default function PrivacyPage() { + return ( +
    +
    +

    Privacy

    +

    + OpenChainBench has no accounts, no sign-in and no payments. It + measures infrastructure, not people. +

    +
    + + +

    + We collect anonymous, aggregate usage statistics so we know which + benchmarks are read. We do not ask for your name, your email or your + wallet, we do not sell or share data with advertisers, and we have no + way to work out who you are from what we store. +

    + + +

    + Page analytics run on PostHog, proxied through this site’s own + origin so your browser never contacts a third-party analytics host + directly. Automatic event capture is switched off: we record a + pageview per route, a page-leave event, and three deliberate + interactions (an outbound link click, a copy, a search). Each visit is + tied to a random identifier stored in your browser, never to an + identity. User profiles are disabled in our configuration and the code + never calls the function that would create one. +

    +

    + A small number of machine-readable surfaces are counted on the server + instead, because no browser renders them: the Markdown views,{" "} + /api/stat and /api/citable. There the + identifier is a daily hash of the requesting user agent. No IP address + is stored. +

    +

    + Public endpoints are rate limited per IP address. Those counters live + in memory in the serving process, are never written to disk, and are + discarded whenever the site is redeployed. +

    + + +
      +
    • No accounts, passwords or email addresses, because there is nothing to sign in to.
    • +
    • No wallet addresses, private keys or API keys. Never send us one.
    • +
    • No advertising or cross-site tracking identifiers, and no data sold or shared with data brokers.
    • +
    • No session recordings, heatmaps or form capture.
    • +
    + + +

    + The Model Context Protocol endpoint at{" "} + {SITE.url}/api/mcp/mcp is public, read-only and requires + no authentication or account. It answers questions about published + benchmark data. It stores nothing about the conversation that reached + it: requests are served and dropped, with only the same anonymous + aggregate counting described above. +

    +

    + When you reach this data through an AI assistant, that assistant has + its own privacy policy covering your conversation. We receive the + question your assistant chooses to send us and nothing else. +

    + + +

    + Benchmark measurements are published openly under CC-BY-4.0. They + describe infrastructure providers, not visitors, and contain no + personal data. Methodology and raw harness code are public in the{" "} + + repository + + . +

    + + +

    + Because we hold no identifying data, we cannot look up, export or + delete “your” records. You can stop the anonymous + analytics at any time by blocking them in your browser or enabling Do + Not Track. If you believe we hold something about you, or you want + anything on this page clarified, write to{" "} + + {SITE.email} + + . +

    +

    + See also the{" "} + + terms of use + {" "} + and the{" "} + + methodology + + . +

    +
    + ); +} diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx new file mode 100644 index 000000000..2dba1ed6a --- /dev/null +++ b/src/app/terms/page.tsx @@ -0,0 +1,137 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { SectionRule } from "@/components/section-rule"; +import { SITE } from "@/data/site"; +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata: Metadata = pageMetadata({ + path: "/terms", + title: "Terms of use", + description: + "Terms for the OpenChainBench site, API and MCP server: CC-BY-4.0 data, MIT code, measured not advisory, no warranty.", +}); + +/** + * Terms of use. + * + * Required to list the MCP server in the ChatGPT app directory (a Terms of + * Service URL is a mandatory submission field), and it did not exist. + * + * Two clauses carry real weight for the app review rather than being + * boilerplate, so do not trim them: + * - "not financial advice", because the directory's review explicitly + * tests that an app refuses investment-advice and trade-execution + * prompts, and the negative test cases we submit point here; + * - the conflict-of-interest disclosure, which already appears in + * public/.well-known/publisher.json and on /partners, and must say the + * same thing in all three places. + */ +export default function TermsPage() { + return ( +
    +
    +

    + Terms of use +

    +

    + Plain terms for the site, the public API and the MCP server. Using + any of them means accepting these. +

    +
    + + +

    + OpenChainBench publishes reproducible benchmarks of crypto + infrastructure: RPC providers, bridges, aggregators, price feeds, + perpetual venues and prediction markets. Every number is produced by + open-source code from a published methodology, and every benchmark + says what it measured, when, and from where. +

    + + +

    + Benchmark data is published under{" "} + + CC-BY-4.0 + + : reuse it anywhere, including commercially, as long as you credit + OpenChainBench and link back to the benchmark page you took it from. + The harness and site code are MIT licensed in the{" "} + + repository + + . +

    + + +

    + Everything here is measurement, not recommendation. Nothing on this + site, in the API or returned by the MCP server is investment, trading, + legal or tax advice, an offer, or a solicitation to buy or sell any + asset. We do not execute transactions, custody funds, route orders or + act on your behalf, and we never will through these interfaces. A + provider ranking first on a benchmark is a statement about one + measured property under one methodology, not an endorsement. +

    +

    + Never send us an API key, a private key, a seed phrase or a signed + transaction. No feature of this site requires one, and any request for + one claiming to be from us is fraudulent. +

    + + +

    + The public API and the MCP endpoint at{" "} + {SITE.url}/api/mcp/mcp are read-only, free and need no + account. They are rate limited per client so one caller cannot deny + the service to others. Please do not attempt to bypass those limits, + scrape at a rate that degrades the service for others, or present the + data as your own measurements. Caching results on your side is + encouraged. +

    + + +

    + Benchmarks are measured from a finite number of vantage points over a + stated window. They can be affected by upstream outages, provider + changes, network conditions and our own bugs. Figures are provided + “as is”, without warranty of any kind, and we accept no + liability for decisions taken on them. Each page states its window and + sample size so you can judge the weight to give it; a benchmark whose + data is stale is marked as such rather than silently served. +

    +

    + Corrections are public. If a number is wrong, open an issue in the + repository or write to{" "} + + {SITE.email} + + ; the fix, and the reason for it, land in the commit history. +

    + + +

    + OpenChainBench is community-run. Mobula is measured here alongside its + direct competitors under identical methodology. This disclosure is + repeated on every relevant benchmark page and on{" "} + + /partners + + . +

    + + +

    + These terms change in public commits like everything else on the site. + Material changes will be noted on this page. See also the{" "} + + privacy policy + + . +

    +
    + ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index e6b7bf901..f505a5d72 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -43,6 +43,15 @@ const PROJECT_LINKS = [ { label: "Contact", href: "/contact" }, ]; +// Legal, kept in its own group after the rule. Both pages are required +// submission fields for the ChatGPT app directory listing of the MCP server. +// They are in the sitemap, but nothing in the rail or the body links them, so +// this row is the only path a reader or a reviewer can follow to reach them. +const LEGAL_LINKS = [ + { label: "Privacy", href: "/privacy" }, + { label: "Terms", href: "/terms" }, +]; + export function SiteFooter() { return (