Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion scripts/dry-run-rpc-hub.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<slug> renders
// (rank among live rows, p50, success, derived error count). Override
// the provider with `--product=<slug>`.
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("");
}

Expand Down
6 changes: 6 additions & 0 deletions src/app/products/[slug]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

Expand DownExpand Up@@ -682,6 +683,11 @@ export default async function ProviderPage({
</ol>
</section>

{/* 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). */}
<RpcProviderChainsSection providerSlug={p.slug} providerName={p.name} />

<RelatedProvidersSection providerSlug={p.slug} providerName={p.name} />

{badgeCards.length > 0 && (
Expand Down
188 changes: 188 additions & 0 deletions src/components/rpc-provider-chains-section.tsx
Original file line numberDiff line numberDiff line change
@@ -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/<slug>. 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 (
<section className="mt-12">
<h2 className="text-[11px] font-medium uppercase tracking-[0.18em] text-ink-muted">
RPC performance by chain
</h2>
<p className="mt-2 text-sm text-ink-soft leading-snug max-w-2xl">
Where {providerName}&apos;s free endpoint ranks on each measured
chain: 24h p50 across 3 probe regions, success rate and failed
probes. Full field on{" "}
<Link href="/rpc" className="lnk">
/rpc
</Link>
.
</p>
<div className="mt-4 overflow-x-auto border-y border-rule">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-rule text-left">
<Th className="pr-3">Chain</Th>
<Th className="px-3 text-right">Rank</Th>
<Th className="px-3 text-right">p50 (24h)</Th>
<Th className="px-3 text-right">Success</Th>
<Th className="pl-3 text-right">Errors (24h)</Th>
</tr>
</thead>
<tbody className="divide-y divide-rule">
{rows.map((r) => (
<tr key={r.chain} className="hover:bg-paper-soft/60 transition-colors">
<td className="py-2.5 pr-3">
<Link
href={`/benchmarks/${r.benchSlug}`}
className="inline-flex items-center gap-2 group"
>
<ProviderLogo slug={r.chain} name={r.chainName} size={18} />
<span className="font-medium text-ink group-hover:underline underline-offset-2">
{r.chainName}
</span>
</Link>
</td>
<td className="py-2.5 px-3 text-right tabular-nums whitespace-nowrap">
{r.rank != null ? (
<>
<span
style={{
color:
r.rank === 1
? "var(--color-good)"
: "var(--color-ink)",
}}
>
#{r.rank}
</span>
<span className="text-ink-faint">/{r.totalRanked}</span>
</>
) : (
<span className="text-[10px] uppercase tracking-[0.14em] text-ink-faint italic">
unresponsive
</span>
)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums whitespace-nowrap">
{r.p50Ms != null ? (
fmtMs(r.p50Ms)
) : (
<span className="text-ink-faint">—</span>
)}
</td>
<td className="py-2.5 px-3 text-right tabular-nums whitespace-nowrap text-ink-soft">
{r.successPct != null ? `${r.successPct.toFixed(2)}%` : "—"}
</td>
<td className="py-2.5 pl-3 text-right tabular-nums whitespace-nowrap text-ink-faint">
{errorCount(r)?.toLocaleString("en-US") ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-[10.5px] text-ink-faint">
Rank counts live providers only; unresponsive endpoints keep
recording success rate but hold no latency percentile. Errors
(24h) = sample size × (1 − success rate).
</p>
</section>
);
}

function Th({
children,
className,
}: {
children: React.ReactNode;
className: string;
}) {
return (
<th
className={`py-2 ${className} text-[10px] font-medium uppercase tracking-[0.16em] text-ink-muted`}
>
{children}
</th>
);
}
85 changes: 75 additions & 10 deletions src/components/rpc-providers-pivot.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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 (
Expand All@@ -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",
);
}
};

Expand DownExpand Up@@ -113,7 +137,37 @@ export function RpcProvidersPivot({
>
Median p50
</ThSort>
<Th>Per-chain rank</Th>
<ThSort
active={sortKey === "medianSuccessPct"}
dir={sortDir}
onClick={() => setSort("medianSuccessPct")}
>
Success
</ThSort>
<ThSort
active={sortKey === "errors24h"}
dir={sortDir}
onClick={() => setSort("errors24h")}
>
Errors (24h)
</ThSort>
{/* 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. */}
<Th>
<span className="sr-only">Per-chain rank</span>
<span className="inline-flex items-center gap-1" aria-hidden>
{chains.map((c) => (
<span
key={c.chain}
title={c.name}
className="inline-flex items-center justify-center w-[22px]"
>
<ProviderLogo slug={c.chain} name={c.name} size={15} />
</span>
))}
</span>
</Th>
</tr>
</thead>
<tbody>
Expand DownExpand Up@@ -169,6 +223,16 @@ export function RpcProvidersPivot({
</Td>
<Td mono>#{fmtRank(r.medianRank)}</Td>
<Td mono>{fmtMs(r.medianP50Ms)}</Td>
<Td mono>
{r.medianSuccessPct != null
? `${r.medianSuccessPct.toFixed(2)}%`
: "—"}
</Td>
<Td mono muted>
{r.errors24h != null
? r.errors24h.toLocaleString("en-US")
: "—"}
</Td>
<Td>
<span className="inline-flex items-center gap-1">
{chains.map((c) => {
Expand DownExpand Up@@ -199,7 +263,7 @@ export function RpcProvidersPivot({
{filtered.length === 0 && (
<tr>
<td
colSpan={6}
colSpan={8}
className="px-3 py-8 text-center text-[12px] text-ink-faint"
>
No provider matches &ldquo;{q}&rdquo;.
Expand All@@ -211,9 +275,10 @@ export function RpcProvidersPivot({
</div>

<p className="px-3 sm:px-4 py-2.5 border-t border-ink/8 text-[10.5px] text-ink-faint">
Per-chain cells show the provider&apos;s leaderboard rank on that
chain (24h p50, all regions). Cell order:{" "}
{chains.map((c) => c.name).join(", ")}.
Per-chain cells show the provider&apos;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).
</p>
</div>
);
Expand Down
Loading
Loading