diff --git a/scripts/dry-run-rpc-hub.ts b/scripts/dry-run-rpc-hub.ts index 8204ea7b..bc1d5ec0 100644 --- a/scripts/dry-run-rpc-hub.ts +++ b/scripts/dry-run-rpc-hub.ts @@ -69,9 +69,36 @@ async function main() { console.log("\nPIVOT (top 10 by coverage):"); for (const r of snap.providersPivot.slice(0, 10)) { console.log( - ` ${r.provider.padEnd(14)} chains=${String(r.chainsCovered).padStart(2)}/${snap.totals.chains} medianRank=#${r.medianRank} medianP50=${r.medianP50Ms} ms`, + ` ${r.provider.padEnd(14)} chains=${String(r.chainsCovered).padStart(2)}/${snap.totals.chains} medianRank=#${r.medianRank} medianP50=${r.medianP50Ms} ms success=${r.medianSuccessPct != null ? `${r.medianSuccessPct.toFixed(2)}%` : "—"} errors24h=${r.errors24h?.toLocaleString("en-US") ?? "—"}`, ); } + + // Product-page extract: the per-chain table /products/ renders + // (rank among live rows, p50, success, derived error count). Override + // the provider with `--product=`. + const productArg = process.argv.find((a) => a.startsWith("--product=")); + const product = productArg?.slice("--product=".length) ?? "drpc"; + console.log(`\nPRODUCT TABLE · /products/${product}:`); + for (const c of snap.chains) { + const idx = c.providers.findIndex((p) => p.provider === product); + if (idx >= 0) { + const p = c.providers[idx]; + const errors = + p.sampleSize != null && p.successPct != null + ? Math.round(p.sampleSize * (1 - p.successPct / 100)) + : null; + console.log( + ` ${c.name.padEnd(14)} rank=#${idx + 1}/${c.providers.length} p50=${String(p.p50Ms).padStart(7)} ms success=${p.successPct != null ? `${p.successPct.toFixed(2)}%` : "—"} errors24h=${errors?.toLocaleString("en-US") ?? "—"}`, + ); + continue; + } + const dead = c.unresponsive?.find((u) => u.provider === product); + if (dead) { + console.log( + ` ${c.name.padEnd(14)} UNRESPONSIVE p50=— success=${dead.successPct != null ? `${dead.successPct.toFixed(2)}%` : "—"} n=${dead.sampleSize ?? "—"}`, + ); + } + } console.log(""); } diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 3ad3525d..c51e8c60 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -29,6 +29,7 @@ import { } from "@/lib/perp-venue-context"; import { PerpVenueSection } from "@/components/perp-venue-section"; import { PmDataFeedSection } from "@/components/pm-data-feed-section"; +import { RpcProviderChainsSection } from "@/components/rpc-provider-chains-section"; export const revalidate = 60; @@ -682,6 +683,11 @@ export default async function ProviderPage({ + {/* Per-chain RPC deep-dive from the rpc-hub cohort snapshot. + Renders nothing for providers outside the free-RPC cluster + (the section fetches the cached snapshot and self-filters). */} + + {badgeCards.length > 0 && ( diff --git a/src/components/rpc-provider-chains-section.tsx b/src/components/rpc-provider-chains-section.tsx new file mode 100644 index 00000000..3aea7c89 --- /dev/null +++ b/src/components/rpc-provider-chains-section.tsx @@ -0,0 +1,188 @@ +import Link from "next/link"; +import { fetchRpcHub } from "@/lib/rpc-hub-stats"; +import { ProviderLogo } from "@/components/provider-logo"; + +/** + * "RPC performance by chain" section for /products/. Renders only + * for providers that appear in at least one chain of the rpc-hub cohort + * snapshot (dRPC, PublicNode, Tenderly, 1RPC, ...): one row per covered + * chain with rank, 24h p50 (3-region aggregate), success rate and the + * derived failed-probe count — the same figures the /rpc pivot shows, + * scoped to one provider. + * + * Server component, snapshot-only (fetchRpcHub reads the worker-written + * cohort blob; zero Prometheus traffic). Rank is the row's index in the + * chain's `providers[]` field, which rpc-hub-stats sorts fastest-first + * over LIVE rows only — unresponsive providers are unranked there, same + * convention as the bench-page ledger, and render here with a dashed + * latency plus their still-recording success rate. Returns null when + * the provider is absent from the snapshot, so non-RPC product pages + * pay one cached read and render nothing. + */ + +type Row = { + chain: string; + chainName: string; + benchSlug: string; + rank: number | null; + totalRanked: number; + p50Ms: number | null; + successPct?: number; + sampleSize?: number; +}; + +function errorCount(row: Row): number | null { + if (row.sampleSize == null || row.successPct == null) return null; + return Math.round(row.sampleSize * (1 - row.successPct / 100)); +} + +function fmtMs(v: number): string { + if (v < 1000) return `${Math.round(v)} ms`; + return `${(v / 1000).toFixed(2)} s`; +} + +export async function RpcProviderChainsSection({ + providerSlug, + providerName, +}: { + providerSlug: string; + providerName: string; +}) { + const snapshot = await fetchRpcHub(); + if (!snapshot) return null; + + const rows: Row[] = []; + for (const c of snapshot.chains) { + const idx = c.providers.findIndex((p) => p.provider === providerSlug); + if (idx >= 0) { + const p = c.providers[idx]; + rows.push({ + chain: c.chain, + chainName: c.name, + benchSlug: c.slug, + rank: idx + 1, + totalRanked: c.providers.length, + p50Ms: p.p50Ms, + successPct: p.successPct, + sampleSize: p.sampleSize, + }); + continue; + } + const dead = c.unresponsive?.find((u) => u.provider === providerSlug); + if (dead) { + rows.push({ + chain: c.chain, + chainName: c.name, + benchSlug: c.slug, + rank: null, + totalRanked: c.providers.length, + p50Ms: null, + successPct: dead.successPct, + sampleSize: dead.sampleSize, + }); + } + } + if (rows.length === 0) return null; + + return ( +
+

+ RPC performance by chain +

+

+ Where {providerName}'s free endpoint ranks on each measured + chain: 24h p50 across 3 probe regions, success rate and failed + probes. Full field on{" "} + + /rpc + + . +

+
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
ChainRankp50 (24h)SuccessErrors (24h)
+ + + + {r.chainName} + + + + {r.rank != null ? ( + <> + + #{r.rank} + + /{r.totalRanked} + + ) : ( + + unresponsive + + )} + + {r.p50Ms != null ? ( + fmtMs(r.p50Ms) + ) : ( + + )} + + {r.successPct != null ? `${r.successPct.toFixed(2)}%` : "—"} + + {errorCount(r)?.toLocaleString("en-US") ?? "—"} +
+
+

+ Rank counts live providers only; unresponsive endpoints keep + recording success rate but hold no latency percentile. Errors + (24h) = sample size × (1 − success rate). +

+
+ ); +} + +function Th({ + children, + className, +}: { + children: React.ReactNode; + className: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/rpc-providers-pivot.tsx b/src/components/rpc-providers-pivot.tsx index 843b445a..883dc051 100644 --- a/src/components/rpc-providers-pivot.tsx +++ b/src/components/rpc-providers-pivot.tsx @@ -21,7 +21,19 @@ import type { RpcHubPivotRow } from "@/lib/rpc-hub-stats"; type ChainRef = { chain: string; name: string; slug: string }; -type SortKey = "chainsCovered" | "medianRank" | "medianP50Ms"; +type SortKey = + | "chainsCovered" + | "medianRank" + | "medianP50Ms" + | "medianSuccessPct" + | "errors24h"; + +/** Optional reliability fields (absent on old snapshots) resolve to + * null so the comparator can pin them to the bottom either way. */ +function sortValue(r: RpcHubPivotRow, k: SortKey): number | null { + const v = r[k]; + return v ?? null; +} export function RpcProvidersPivot({ rows, @@ -46,8 +58,17 @@ export function RpcProvidersPivot({ : rows; const factor = sortDir === "desc" ? -1 : 1; return [...out].sort((a, b) => { - const av = a[sortKey]; - const bv = b[sortKey]; + const av = sortValue(a, sortKey); + const bv = sortValue(b, sortKey); + // Rows without the metric (old snapshot, no sampleSize) sink to + // the bottom regardless of direction. + if (av == null && bv == null) { + return ( + b.chainsCovered - a.chainsCovered || a.medianRank - b.medianRank + ); + } + if (av == null) return 1; + if (bv == null) return -1; if (av === bv) { // Stable tie-breaks: coverage, then rank quality. return ( @@ -62,8 +83,11 @@ export function RpcProvidersPivot({ if (k === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc")); else { setSortKey(k); - // Coverage reads best descending; rank + latency ascending. - setSortDir(k === "chainsCovered" ? "desc" : "asc"); + // Coverage + success read best descending; rank, latency and + // error counts ascending. + setSortDir( + k === "chainsCovered" || k === "medianSuccessPct" ? "desc" : "asc", + ); } }; @@ -113,7 +137,37 @@ export function RpcProvidersPivot({ > Median p50 - Per-chain rank + setSort("medianSuccessPct")} + > + Success + + setSort("errors24h")} + > + Errors (24h) + + {/* Chain logos double as column headers for the rank + strip below: same 22px slots + gap as the cells, so + each rank square reads under its chain mark. */} + + Per-chain rank + + {chains.map((c) => ( + + + + ))} + + @@ -169,6 +223,16 @@ export function RpcProvidersPivot({ #{fmtRank(r.medianRank)} {fmtMs(r.medianP50Ms)} + + {r.medianSuccessPct != null + ? `${r.medianSuccessPct.toFixed(2)}%` + : "—"} + + + {r.errors24h != null + ? r.errors24h.toLocaleString("en-US") + : "—"} + {chains.map((c) => { @@ -199,7 +263,7 @@ export function RpcProvidersPivot({ {filtered.length === 0 && ( No provider matches “{q}”. @@ -211,9 +275,10 @@ export function RpcProvidersPivot({

- Per-chain cells show the provider's leaderboard rank on that - chain (24h p50, all regions). Cell order:{" "} - {chains.map((c) => c.name).join(", ")}. + Per-chain cells show the provider's leaderboard rank on the + chain marked by the logo above (24h p50, all regions). Success is + the median 24h success rate across covered chains; Errors (24h) is + the summed failed-probe count (sample size × failure rate).

); diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts index 88dd3856..8936a86f 100644 --- a/src/lib/rpc-hub-stats.ts +++ b/src/lib/rpc-hub-stats.ts @@ -58,6 +58,15 @@ export type RpcHubProvider = { regions: Partial>; }; +export type RpcHubUnresponsiveProvider = { + provider: string; + name: string; + /** Success rate over 24h (%, 2 decimals). Call counters keep + * recording through an outage, so this stays meaningful. */ + successPct?: number; + sampleSize?: number; +}; + export type RpcHubChain = { /** Chain slug, e.g. "ethereum". */ chain: string; @@ -72,6 +81,9 @@ export type RpcHubChain = { * (probed, all calls failing, no latency). Never part of * best/fastest computations — display-only context. */ unresponsiveCount?: number; + /** The unresponsive rows themselves (never ranked). Additive optional + * field: old blobs without it still parse. */ + unresponsive?: RpcHubUnresponsiveProvider[]; best: RpcRegionBest | null; /** Best provider per probe region. */ regions: Partial>; @@ -89,7 +101,17 @@ export type RpcHubPivotRow = { chainsCovered: number; medianRank: number; medianP50Ms: number; - chains: Record; + /** Median 24h success rate across covered chains (%, 2 decimals). + * Optional: absent from older blobs and success-less rows. */ + medianSuccessPct?: number; + /** Failed probes over 24h summed across covered chains: + * Σ round(sampleSize × (1 − successPct/100)). Optional (needs + * sampleSize; absent from older blobs). */ + errors24h?: number; + chains: Record< + string, + { p50Ms: number; rank: number; successPct?: number; sampleSize?: number } + >; }; export type RpcHubSnapshot = { @@ -105,6 +127,7 @@ export type RpcHubSnapshot = { const RPC_HUB_KEY = "rpc-hub"; const round1 = (v: number) => Math.round(v * 10) / 10; +const round2 = (v: number) => Math.round(v * 100) / 100; function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b); @@ -201,8 +224,9 @@ async function buildChain(spec: Spec): Promise { const leaderSeries = bench.extras.series24h?.[leader.slug]; // Unresponsive rows are excluded from `rows` by liveRows (they carry // availability="unavailable" and zero latency), so they can't touch - // best/fastest — surface only their count for the chains table. - const unresponsiveCount = bench.results.filter((r) => r.unresponsive).length; + // best/fastest — surface count + identity/success for display-only + // consumers (chains table, product pages). + const unresponsiveRows = bench.results.filter((r) => r.unresponsive); return { chain, @@ -210,7 +234,21 @@ async function buildChain(spec: Spec): Promise { name: chainLabelForSlug(chain) ?? chain, benchTitle: spec.title, providerCount: rows.length, - ...(unresponsiveCount > 0 ? { unresponsiveCount } : {}), + ...(unresponsiveRows.length > 0 + ? { + unresponsiveCount: unresponsiveRows.length, + unresponsive: unresponsiveRows.map((r) => ({ + provider: r.slug, + name: r.name, + ...(Number.isFinite(r.successRate) + ? { successPct: round2(r.successRate) } + : {}), + ...(r.sampleSize != null + ? { sampleSize: Math.round(r.sampleSize) } + : {}), + })), + } + : {}), best: { provider: leader.slug, providerName: leader.name, p50Ms: round1(leader.ms.p50) }, regions, providers: rows.map((r) => ({ @@ -220,7 +258,7 @@ async function buildChain(spec: Spec): Promise { p99Ms: Number.isFinite(r.ms.p99) && r.ms.p99 > 0 ? round1(r.ms.p99) : undefined, successPct: Number.isFinite(r.successRate) && r.successRate > 0 - ? round1(r.successRate) + ? round2(r.successRate) : undefined, sampleSize: r.sampleSize != null ? Math.round(r.sampleSize) : undefined, regions: regionP50[r.slug] ?? {}, @@ -237,25 +275,48 @@ async function buildChain(spec: Spec): Promise { function buildPivot(chains: RpcHubChain[]): RpcHubPivotRow[] { const acc = new Map< string, - { name: string; chains: Record } + { name: string; chains: RpcHubPivotRow["chains"] } >(); for (const c of chains) { c.providers.forEach((p, i) => { const entry = acc.get(p.provider) ?? { name: p.name, chains: {} }; - entry.chains[c.chain] = { p50Ms: p.p50Ms, rank: i + 1 }; + entry.chains[c.chain] = { + p50Ms: p.p50Ms, + rank: i + 1, + ...(p.successPct != null ? { successPct: p.successPct } : {}), + ...(p.sampleSize != null ? { sampleSize: p.sampleSize } : {}), + }; acc.set(p.provider, entry); }); } const rows: RpcHubPivotRow[] = [...acc.entries()].map( ([provider, { name, chains: perChain }]) => { const cells = Object.values(perChain); + // Reliability aggregates over covered chains only. Success is a + // median (robust to one bad chain); errors are an absolute sum of + // failed probes, same derivation as the ledger's Errors column. + const successes = cells + .map((c) => c.successPct) + .filter((v): v is number => v != null); + const errorCells = cells.filter( + (c) => c.sampleSize != null && c.successPct != null, + ); + const errors24h = errorCells.reduce( + (sum, c) => + sum + Math.round((c.sampleSize as number) * (1 - (c.successPct as number) / 100)), + 0, + ); return { provider, name, chainsCovered: cells.length, medianRank: round1(median(cells.map((c) => c.rank))), medianP50Ms: round1(median(cells.map((c) => c.p50Ms))), + ...(successes.length > 0 + ? { medianSuccessPct: round2(median(successes)) } + : {}), + ...(errorCells.length > 0 ? { errors24h } : {}), chains: perChain, }; }, @@ -340,8 +401,10 @@ async function fetchRpcHubRaw(): Promise { const fetchRpcHubCached = unstable_cache( fetchRpcHubRaw, + // v3: pivot rows gained medianSuccessPct/errors24h + per-chain + // successPct/sampleSize; chains gained unresponsive[] rows. // v2: chains gained unresponsiveCount (unresponsive provider rows). - ["rpc-hub-cohort-v2"], + ["rpc-hub-cohort-v3"], { revalidate: 60, tags: ["rpc-cohort"] }, );