From 3641d467fc011e2903c12d492d22a7cfb15908f6 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 19:24:11 +0300 Subject: [PATCH 001/174] chore(obs): temporary [DRAFT-TRACE] logs on bench draft fallback paths (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs every fork that produces a visible draft render to the user: kv_hit / kv_miss / kv_empty / kv_shape / kv_stale / kv_timeout / kv_neterr / kv_http / collapse / placeholder_used Includes slug + timing per call. All prefixed with [DRAFT-TRACE] for easy grep in Vercel logs. Plain console.warn — no extra deps. Revert this commit once we have the cause pinned. --- src/lib/snapshot.ts | 26 +++++++++++++++++--------- src/lib/spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index 52fc30d6..eff4d999 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -139,21 +139,27 @@ export async function readSnapshot( signal: AbortSignal.timeout(2_000), }, ); - if (!res.ok) return null; + if (!res.ok) { + console.warn(`[DRAFT-TRACE] kv_http slug=${slug} status=${res.status}`); + return null; + } const env = (await res.json()) as { result?: string | null }; - if (!env.result) return null; + if (!env.result) { + console.warn(`[DRAFT-TRACE] kv_empty slug=${slug} (no snapshot ever written)`); + return null; + } const raw = JSON.parse(env.result); const parsed = SnapshotSchema.safeParse(raw); if (!parsed.success) { console.warn( - `snapshot.read rejected (shape) for ${slug}: ${parsed.error.message}`, + `[DRAFT-TRACE] kv_shape slug=${slug} err=${parsed.error.message}`, ); return null; } const age = Date.now() - parsed.data.savedAt; if (age > MAX_SNAPSHOT_AGE_MS) { console.warn( - `snapshot.read rejected (stale ${Math.round(age / 1000 / 60)}m) for ${slug}`, + `[DRAFT-TRACE] kv_stale slug=${slug} age_min=${Math.round(age / 1000 / 60)}`, ); return null; } @@ -164,11 +170,13 @@ export async function readSnapshot( lastRunAt: parsed.data.lastRunAt, }; } catch (err) { - console.warn( - `snapshot.read failed for ${slug}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); + const msg = err instanceof Error ? err.message : String(err); + // Distinguish abort/timeout (most common) from other network errors so + // the post-incident grep can see if it's KV slowness vs KV down. + const tag = msg.toLowerCase().includes("abort") || msg.toLowerCase().includes("timeout") + ? "kv_timeout" + : "kv_neterr"; + console.warn(`[DRAFT-TRACE] ${tag} slug=${slug} err=${msg}`); return null; } } diff --git a/src/lib/spec.ts b/src/lib/spec.ts index f5be108a..b7e18659 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -42,7 +42,9 @@ const loadBenchmarkUnfilteredCached = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; + const promStart = Date.now(); const bench = await specToBenchmark(spec); + const promMs = Date.now() - promStart; if (spec.status === "live" && bench.status === "draft") { // Live spec, but Prom returned nothing this cycle. Try the // persistent snapshot before giving up. This is the cold-start @@ -51,8 +53,18 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // good data; without KV we throw to preserve any previous cache // value (or eventually fall through to the draft placeholder in // the aggregator). + // [DRAFT-TRACE] temporary observability — remove once we've pinned + // the cause of intermittent draft renders. + console.warn( + `[DRAFT-TRACE] collapse slug=${slug} prom_ms=${promMs} → trying KV snapshot`, + ); + const kvStart = Date.now(); const snap = await readSnapshot(slug); + const kvMs = Date.now() - kvStart; if (snap) { + console.warn( + `[DRAFT-TRACE] kv_hit slug=${slug} kv_ms=${kvMs} → serving snapshot`, + ); const editorial = buildEditorial(spec); const reconstructed = renderBenchmarkText({ ...editorial, ...snap }); // The reconstructed bench is live data, just sourced from KV @@ -63,6 +75,9 @@ const loadBenchmarkUnfilteredCached = unstable_cache( } return reconstructed; } + console.warn( + `[DRAFT-TRACE] kv_miss slug=${slug} kv_ms=${kvMs} → throwing to keep prev cache`, + ); throw new Error( `loadBenchmark(${slug}): live spec collapsed to draft, keeping prev cache`, ); @@ -99,6 +114,13 @@ const loadAllBenchmarksCached = unstable_cache( // to. Surface a placeholder so the page still renders rather // than dropping the bench from the list (which would break the // sitemap, the products pages, and the "More benchmarks" rail). + // [DRAFT-TRACE] this is the path that produces a visible "draft" + // render to the user — log so we can correlate with KV / prom state. + const reason = + r.status === "rejected" ? (r.reason instanceof Error ? r.reason.message : String(r.reason)) : "no_value"; + console.warn( + `[DRAFT-TRACE] placeholder_used slug=${spec.slug} reason=${reason}`, + ); benchmarks.push(draftPlaceholderForSpec(spec)); } } From 026cc0e166b7943c38be200eeee21774b11a98c7 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 19:28:35 +0300 Subject: [PATCH 002/174] =?UTF-8?q?fix(harness):=20bump=20Astralane=20defa?= =?UTF-8?q?ult=20tip=20500k=20=E2=86=92=201M=20lamports=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(obs): temporary [DRAFT-TRACE] logs on bench draft fallback paths Logs every fork that produces a visible draft render to the user: kv_hit / kv_miss / kv_empty / kv_shape / kv_stale / kv_timeout / kv_neterr / kv_http / collapse / placeholder_used Includes slug + timing per call. All prefixed with [DRAFT-TRACE] for easy grep in Vercel logs. Plain console.warn — no extra deps. Revert this commit once we have the cause pinned. * fix(solana-tx-landing harness): bump Astralane default tip to 1M lamports Sync from the private mobula-api harness. Astralane raised their min tip floor 500_000 → 1_000_000; keeps the public harness reproducing the same probe config. --- harnesses/solana-tx-landing/cmd/script/senders.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/harnesses/solana-tx-landing/cmd/script/senders.go b/harnesses/solana-tx-landing/cmd/script/senders.go index b1b74070..d8f55c9c 100644 --- a/harnesses/solana-tx-landing/cmd/script/senders.go +++ b/harnesses/solana-tx-landing/cmd/script/senders.go @@ -273,7 +273,10 @@ func buildAstralaneProbe(tip solana.PublicKey) serviceProbe { } host := envDefault("ASTRALANE_ENDPOINT", "https://ny.gateway.astralane.io/iris") url := host + "?api-key=" + key - tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 500_000)) + // Astralane raised their min tip floor 500_000 → 1_000_000 lamports + // (observed 2026-05-26 via prober alerts). Default to the new floor; + // the ASTRALANE_TIP_LAMPORTS env var still overrides. + tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 1_000_000)) return serviceProbe{ Service: ServiceAstralane, From 0a994c672bdd360fb97c9eaf798b3f1a4cd82ab3 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 22:31:17 +0300 Subject: [PATCH 003/174] =?UTF-8?q?fix(perf):=20bump=20Prom=20timeout=204s?= =?UTF-8?q?=E2=86=9210s=20+=20cron=20pre-warms=20bench=20cache=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes pinned via [DRAFT-TRACE] logs on staging: 1. Prom queries quantile_over_time(0.99, …[24h]) consistently take 4-8s under load. The 4s client timeout aborted, collapsing benches to draft when the per-bench KV snapshot wasn't available as fallback. Bump to 10s — fits the observed worst case with headroom. 2. The /api/cron/health-check route runs every 5 min but only computed Slack transitions. Add a loadAllBenchmarks() call so the per-bench unstable_cache stays warm and writeSnapshot() refreshes the KV snapshot layer. Eliminates the cold-start window where a fresh Vercel function instance has no in-memory cache AND the snapshot is stale or missing — exactly the pattern that produces the visible 'bench in draft' symptom users observed. --- src/app/api/cron/health-check/route.ts | 18 +++++++++++++++++- src/lib/prometheus.ts | 9 +++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/app/api/cron/health-check/route.ts b/src/app/api/cron/health-check/route.ts index 0c5d2c48..7892bede 100644 --- a/src/app/api/cron/health-check/route.ts +++ b/src/app/api/cron/health-check/route.ts @@ -1,7 +1,7 @@ import { timingSafeEqual } from "node:crypto"; import { NextResponse, type NextRequest } from "next/server"; import { getBenchmarkSlugs } from "@/data/benchmarks"; -import { getSpecs } from "@/lib/spec"; +import { getSpecs, loadAllBenchmarks } from "@/lib/spec"; import { extractMetricName, Prometheus } from "@/lib/prometheus"; export const runtime = "nodejs"; @@ -214,12 +214,28 @@ export async function GET(req: NextRequest) { } } + // Pre-warm the per-bench unstable_cache + KV snapshot layer. This + // single call is what spares an unlucky cold-start user a draft render + // when Prom is slow: it keeps each bench's runtime-cache entry fresh + // and triggers a writeSnapshot to KV so the snapshot fallback always + // has a recent good value to surface. Failures are caught so a Prom + // hiccup here doesn't 500 the cron and lose the Slack alert. + let prewarmCount = 0; + let prewarmErr: string | undefined; + try { + const benches = await loadAllBenchmarks(); + prewarmCount = benches.length; + } catch (err) { + prewarmErr = err instanceof Error ? err.message : String(err); + } + return NextResponse.json({ checked: liveSpecs.length, transitions: transitions.length, sent: sent.length, dryRun: !webhook, transitionsList: transitions, + prewarm: { count: prewarmCount, err: prewarmErr }, }); } diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index f9186f70..92afbd02 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -17,8 +17,13 @@ type PromEnvelope = | { status: "success"; data: T; warnings?: string[] } | { status: "error"; errorType: string; error: string }; -/** Default timeout for Prometheus queries. keep short, build time is finite. */ -const DEFAULT_TIMEOUT_MS = 4_000; +/** Default timeout for Prometheus queries. Bumped 4s → 10s after staging + * logs showed `quantile_over_time(0.99, …[24h])` consistently taking + * 4-8s under load (observed 2026-05-26), which would otherwise abort + * the query and collapse the bench to a draft render. 10s is the safe + * upper bound for our heaviest percentile-over-window queries while + * still bounding the SSR render at a tolerable ceiling. */ +const DEFAULT_TIMEOUT_MS = 10_000; export class Prometheus { constructor(public readonly baseUrl: string) { From 002c8264fda37dfdeb04bf2737299955fb4c9d34 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 22:37:25 +0300 Subject: [PATCH 004/174] fix(logo): wrap 1RPC / Blocknative / Lava / Tenderly / Jito / Astralane in light chip (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(perf): bump Prom timeout 4s→10s + cron pre-warms bench cache Two root causes pinned via [DRAFT-TRACE] logs on staging: 1. Prom queries quantile_over_time(0.99, …[24h]) consistently take 4-8s under load. The 4s client timeout aborted, collapsing benches to draft when the per-bench KV snapshot wasn't available as fallback. Bump to 10s — fits the observed worst case with headroom. 2. The /api/cron/health-check route runs every 5 min but only computed Slack transitions. Add a loadAllBenchmarks() call so the per-bench unstable_cache stays warm and writeSnapshot() refreshes the KV snapshot layer. Eliminates the cold-start window where a fresh Vercel function instance has no in-memory cache AND the snapshot is stale or missing — exactly the pattern that produces the visible 'bench in draft' symptom users observed. * fix(logo): wrap dark-toned provider logos in light chip 1RPC, Blocknative, Lava, Tenderly, Jito and Astralane have single-tone (mostly dark) artwork that disappears against the page background on small bench cards. Add to NEEDS_LIGHT_CHIP so they render on a white chip with a hairline shadow — same treatment as Relay, Debridge, Tonapi. --- src/components/provider-logo.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index 23615371..edc9cd5d 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -68,6 +68,13 @@ const NEEDS_LIGHT_CHIP = new Set([ "relay", "debridge", "tonapi", + // Dark/single-tone logos that need a white chip to read on the page bg: + "1rpc", + "blocknative", + "lava", + "tenderly", + "jito", + "astralane", ]); function Chip({ From 0825020b74798611beaa4ab4c0ad99dbad508fc3 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 22:38:26 +0300 Subject: [PATCH 005/174] fix(logo): also chip dydx v4 and Sky maker (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same single-tone artwork pattern as the previous batch — invisible on the bench card background. Wrap in white chip for contrast. --- src/components/provider-logo.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index edc9cd5d..b76de8db 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -75,6 +75,8 @@ const NEEDS_LIGHT_CHIP = new Set([ "tenderly", "jito", "astralane", + "dydx", + "sky", ]); function Chip({ From 0834a830b9b119ba77b1b82ac0548299540134b9 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 22:41:27 +0300 Subject: [PATCH 006/174] =?UTF-8?q?fix(logo):=20chip=20Merkle,=20Moralis,?= =?UTF-8?q?=20Nodies=20=E2=80=94=20invisible=20in=20dark=20mode=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/provider-logo.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index b76de8db..0f4c4d6e 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -77,6 +77,11 @@ const NEEDS_LIGHT_CHIP = new Set([ "astralane", "dydx", "sky", + // Dark-tone logos that vanish on the dark-mode page bg (the white chip + // gives them a visible surface in both light and dark modes): + "merkle", + "moralis", + "nodies", ]); function Chip({ From 941cb155bd4b3e47da8b966b4c2747c7b63a53cd Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 22:46:25 +0300 Subject: [PATCH 007/174] fix(logo): introduce NEEDS_DARK_CHIP for white-fill marks (Jito, Astralane, Sky, dydx) (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified via SVG fill audit: those 4 logos have fill="white" content, which the previous white chip rendered as invisible-on-invisible. Split into two sets — light chip (#fff for dark logos) and dark chip (#0f172a for white logos). --- src/components/provider-logo.tsx | 49 +++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index 0f4c4d6e..be5d683d 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -32,11 +32,27 @@ export function ProviderLogo({ return ; } - // A handful of brand marks are dark-on-transparent and disappear on the - // dark-mode page background. Force a white coaster + thin ring for those - // specific slugs so they stay legible without altering the look of every - // other (already-colored) logo. - const needsLightChip = NEEDS_LIGHT_CHIP.has(slug.toLowerCase()); + // Brand marks come in two failure modes against our page background: + // • dark-on-transparent → invisible on dark-mode page → light chip + // • white-on-transparent → invisible on white light-mode page → dark chip + // Each problematic slug is opted in to ONE of the two sets below. Logos + // with proper colored fills (Helius orange, GeckoTerminal purple, …) + // need neither and ride the default transparent paper background. + const lc = slug.toLowerCase(); + const needsLightChip = NEEDS_LIGHT_CHIP.has(lc); + const needsDarkChip = NEEDS_DARK_CHIP.has(lc); + let background: string; + let boxShadow: string | undefined; + if (needsDarkChip) { + background = "#0f172a"; + boxShadow = "0 0 0 1px rgba(15, 23, 42, 0.18)"; + } else if (needsLightChip) { + background = "#ffffff"; + boxShadow = "0 0 0 1px rgba(15, 23, 42, 0.08)"; + } else { + background = "var(--color-paper)"; + boxShadow = undefined; + } return ( // eslint-disable-next-line @next/next/no-img-element @@ -53,13 +69,15 @@ export function ProviderLogo({ width: size, height: size, borderRadius: "50%", - background: needsLightChip ? "#ffffff" : "var(--color-paper)", - boxShadow: needsLightChip ? "0 0 0 1px rgba(15, 23, 42, 0.08)" : undefined, + background, + boxShadow, }} /> ); } +// Dark-tone (or dark-on-transparent) logos — get a WHITE chip with a +// hairline shadow so they pop on both light and dark page backgrounds. const NEEDS_LIGHT_CHIP = new Set([ "mobula", "lighter", @@ -68,22 +86,25 @@ const NEEDS_LIGHT_CHIP = new Set([ "relay", "debridge", "tonapi", - // Dark/single-tone logos that need a white chip to read on the page bg: "1rpc", "blocknative", "lava", "tenderly", - "jito", - "astralane", - "dydx", - "sky", - // Dark-tone logos that vanish on the dark-mode page bg (the white chip - // gives them a visible surface in both light and dark modes): "merkle", "moralis", "nodies", ]); +// White-on-transparent logos — invisible on a white chip. They get a +// near-black chip so the white artwork pops. Verified via SVG fill audit +// (fill="white" present in the brand mark). +const NEEDS_DARK_CHIP = new Set([ + "jito", + "astralane", + "sky", + "dydx", +]); + function Chip({ slug, name, From 3e7c8bb5986a1074b32884b539fede07035adaa5 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Tue, 26 May 2026 22:52:03 +0300 Subject: [PATCH 008/174] fix(build): tolerate all-bench-draft state instead of crashing build (#154) The safety throw 'every bench draft, refusing to update cache' crashes next build when Prom is fully unreachable during the build window. Replace the throw with a [DRAFT-TRACE] warning + return the draft array. With the snapshot/KV fallback + 10s Prom timeout + cron pre-warm already mitigating user-visible drafts, this safety net's value is marginal. --- src/lib/spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index b7e18659..10e5136e 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -126,8 +126,18 @@ const loadAllBenchmarksCached = unstable_cache( } const live = benchmarks.filter((b) => b.status === "live"); if (benchmarks.length > 0 && live.length === 0) { - throw new Error( - "loadAllBenchmarks: every bench draft, refusing to update cache", + // Previously this threw to make unstable_cache keep the previous + // value during a Prom blackout. The throw, however, propagates + // unhandled into the BUILD-time page-generation path (no previous + // cache there) and crashes `next build` with "Error: every bench + // draft". With the snapshot/KV fallback + 10s Prom timeout + cron + // pre-warm now in place, this safety net mostly fires during the + // build window of a cold deploy. Warn loudly and return the + // draft set — the page renders with placeholders and recovers on + // the next revalidate cycle. Avoids deploy crashes. + console.warn( + "[DRAFT-TRACE] all_draft slug_count=" + benchmarks.length + + " — Prom blackout during build/cold-start, returning placeholders", ); } return benchmarks.sort((a, b) => a.number.localeCompare(b.number)); From 7be72a828a696fb8f3adc56dcfe170abbb02e3ef Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 14:46:09 +0300 Subject: [PATCH 009/174] crop 1rpc/tenderly/lava logos to icon-only (#155) --- public/logos/1rpc.svg | 2 +- public/logos/lava.webp | Bin 1746 -> 5934 bytes public/logos/tenderly.svg | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/logos/1rpc.svg b/public/logos/1rpc.svg index 90e7f453..5494a3e3 100644 --- a/public/logos/1rpc.svg +++ b/public/logos/1rpc.svg @@ -1,4 +1,4 @@ - + diff --git a/public/logos/lava.webp b/public/logos/lava.webp index 4c90a195aec17d66d92d4c7002879e92fb60dd1d..8b4c007678cfac616df3253a7236b5d26ec01c1b 100644 GIT binary patch literal 5934 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 Qtz7Q6W|P9z_*--U0M5@#1^@s6 literal 1746 zcmV;@1}*tgNk&G>1^@t8MM6+kP&il$0000G0002S001`t06|PpNSXrx00EFy+iuuO z7lI%NLLeAIA(+7ngPK9Zz-7>3U{>3(IKD|EM5CfVz5 zRhypfRkry;cZ%f%d)=vOr>6e^G4s`!nHETP?)PG}H#IZUmVZ)l6M((A9OB{+Y|o}< zW>VA7UR;_$a*~xR(Nk?uGEmGd0N&;vp=xgJY|Tc60Wp^+yzKPtI!P?}97~>3fYf8} zWkKCeU_1YNnWx3~AvDMKKsLAGmzsr@=g9)LU*u^sKSRlD0r9f)wTa(pPg_y>5)V7m zfjX9klDUH4mz^16TeDq(rS~n5ZA(Qa6t4|cFaKogw>@!syyXm8#P$nR^EIQJB}D2N z4Y2wABb{$Tx6FIms>;i;`N9~aZv=-|23}qTfNcJUFvv2AZQZt9?J$Pv8%ds0Pdxq+ zPYA=C&XI*|UroGPWgkyp%X8TgbJ7N?nUUjOMov)W-Irg;Gl~;OuIR=T4I!XZ8 zTxCZ*ilq?ia`yQj;c%<~$XlE*ZZ80>* z5|X(MffPFete3FQI@!}$oshhSkN4=jy(1=pY8Oq*uxBa4G6Cw?!(2gk_mm>p#i z=!|^`;t2(+%_v?s_cqU70hTeA%h+qm;eE!6XI~n6+7!|u&5r@JfqxiIyYfu)yNnjX%rXU=j*O~jCL?Je zjiC$;s%K^uKx&uJLYPZB1&=AmX9}bvh?Xf?$AHw9(?XcO=@e`d`y$OLog6}$P*5U3 zlBKi|&SaSaen$=h$`n#ZD25~f-2svikYyPygtODTX$;`^NK5!FGfHE+Uk+UPUgq)X`i|$^tbZ))SZTaEaz3mJ~e8lU>~79 zBz(Ymx%vSAaq1WSuc%|t1HxzEm-gTG)7kSiYx;SCLV{5Y2=Mw@V4{a$bFDyxkbaF> z@U|y8dl+(E*8bpSmS@KIMX{W^M#Zx7OAH)bAVgpq3PHQX5@AwZE&EUOcGnAK~pGNHxR?lSziR{doKRMh$_q_r#H0 zNN-z!_y5RRq(EkMecD&01Ent0xobC)AnXXdXD^(}H4!f|Yz(FBZVO+H>dm+~ey@eH1 zV8M1+l$SQ7HDDbjlCnM7Ta+f(@oTac<0G`W+V-x3wmeIvW=6f%EU>c*I)Bpe2!xs0 z_rLqm$ZU_UvlW{06ffoguR0o@2ziekNR%ZK7RlbN_&7(@tfih(s=15LJLSam`|vrc zQ{3#G9Cgn>ov?7Wi@iI2GRQbuLU7xAc< z^A!0%>C8#3SJFA51pCi_;qTmrQ<7zi@LH1O%l<)!?1 zyn6q)>tM($WX*$cCpTD%af569X}``Z$=bs5A9!?1zJYTZm+noUPDR4Xcr!9*LM*T% oTIuRg`5yEn4AO)f@)(BT@Cq8ptK`XZ{{o4?U^fv0U;qFB0Fwe=v;Y7A diff --git a/public/logos/tenderly.svg b/public/logos/tenderly.svg index c0bf3dd8..b3c05826 100644 --- a/public/logos/tenderly.svg +++ b/public/logos/tenderly.svg @@ -1,5 +1,5 @@ - + 6PX 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 Z*@m9oh8^s3;QdW6WF?g(s>F - - - - - - - - \ No newline at end of file diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index c6141987..8b798c82 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -73,6 +73,36 @@ export const PROVIDER_REGISTRY: Record = { "Solana RPC and data provider. Enhanced transactions, webhooks, DAS API for assets, and standard JSON-RPC.", twitter: "@heliuslabs", }, + dune: { + url: "https://dune.com", + description: + "Onchain analytics platform. SQL-queryable indexed data across EVM chains and Solana, public dashboards, plus the Sim onchain-data API for wallet, transaction, and token endpoints.", + twitter: "@DuneAnalytics", + }, + "sim-dune": { + url: "https://sim.dune.com", + description: + "Onchain-data REST API by Dune. EVM wallet balances, transactions, token info, holders, collectibles, and DeFi positions across 60+ mainnets.", + twitter: "@DuneAnalytics", + }, + coinpaprika: { + url: "https://coinpaprika.com", + description: + "Independent crypto market data API. Token prices, OHLCV, exchange tickers, and contract/platform lookups across 300+ supported chains. Public free tier with no auth.", + twitter: "@coinpaprika", + }, + covalent: { + url: "https://goldrush.dev", + description: + "Multichain onchain data API by GoldRush (formerly Covalent). Unified REST endpoints for wallet balances, transactions, NFTs, and pricing across 100+ EVM chains.", + twitter: "@goldrushdev", + }, + coinstats: { + url: "https://coinstats.app", + description: + "Portfolio tracker and market data API. REST endpoints for prices, exchanges, NFTs, and wallet balances across 100+ blockchains. Consumer app + paid API tiers.", + twitter: "@CoinStats", + }, // ─── Solana transaction landing services ────────────────────── jito: { diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index afc7ee58..21ad2408 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -120,7 +120,7 @@ const RAW: Record = { birdeye: "/logos/birdeye.png", bitquery: "/logos/bitquery.png", coingecko: "/logos/coingecko.webp", - dune: "/logos/dune.svg", + dune: "/logos/dune.png", "pump-portal": "/logos/pump-portal.svg", quicknode: "/logos/quicknode.svg", "the-graph": "/logos/the-graph.svg", From 62b7fb8b0e98339fb722999f3a4d7c004d1e6589 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 16:57:07 +0300 Subject: [PATCH 013/174] fix(bench-005): rename sim-dune to dune + sync OSS harness mirror with 4 providers (#159) --- benchmarks/network-coverage.yml | 32 +++---- .../cmd/script/coinpaprika.go | 56 +++++++++++++ .../network-coverage/cmd/script/coinstats.go | 62 ++++++++++++++ .../network-coverage/cmd/script/config.go | 11 ++- .../network-coverage/cmd/script/covalent.go | 83 +++++++++++++++++++ harnesses/network-coverage/cmd/script/main.go | 4 + .../network-coverage/cmd/script/simdune.go | 76 +++++++++++++++++ src/data/provider-registry.ts | 8 +- src/lib/logo-manifest.ts | 2 - 9 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 harnesses/network-coverage/cmd/script/coinpaprika.go create mode 100644 harnesses/network-coverage/cmd/script/coinstats.go create mode 100644 harnesses/network-coverage/cmd/script/covalent.go create mode 100644 harnesses/network-coverage/cmd/script/simdune.go diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml index 3ae529d1..1da9e4a2 100644 --- a/benchmarks/network-coverage.yml +++ b/benchmarks/network-coverage.yml @@ -3,8 +3,8 @@ slug: network-coverage number: "005" title: Onchain data API with most networks supported -seo_title: "Crypto data API with the most blockchains supported in 2026: Mobula, Codex, GeckoTerminal, Covalent, CoinPaprika, CoinStats, Sim by Dune live" -seo_description: "Live blockchain coverage leaderboard. How many mainnets each onchain data API (Mobula, Codex, GeckoTerminal, Covalent/GoldRush, CoinPaprika, CoinStats, Sim by Dune) officially supports right now, audited every six hours against their public network endpoints." +seo_title: "Crypto data API with the most blockchains supported in 2026: Mobula, Codex, GeckoTerminal, Covalent, CoinPaprika, CoinStats, Dune live" +seo_description: "Live blockchain coverage leaderboard. How many mainnets each onchain data API (Mobula, Codex, GeckoTerminal, Covalent/GoldRush, CoinPaprika, CoinStats, Dune) officially supports right now, audited every six hours against their public network endpoints." subtitle: Number of blockchains each major onchain data provider officially supports. category: Aggregators status: live @@ -33,8 +33,8 @@ abstract: | We benchmark how many networks each major onchain data provider lists in its public "supported networks" endpoint. The harness fetches the official listing every six hours from seven providers (GeckoTerminal, - Codex, Mobula, CoinPaprika, Covalent/GoldRush, CoinStats and Sim by - Dune), deduplicates by chain id and counts. Mainnet only, + Codex, Mobula, CoinPaprika, Covalent/GoldRush, CoinStats and Dune via + Sim API), deduplicates by chain id and counts. Mainnet only, testnets are excluded because providers list them inconsistently and the comparison is meant to reflect what a builder can integrate against in production. Coverage breadth is one dimension of a data provider's @@ -49,7 +49,7 @@ methodology: - "CoinPaprika: GET /v1/contracts (no auth) — list of platforms supported for contract lookup." - "Covalent / GoldRush: GET /v1/chains/ with a Bearer API key; testnets filtered via `is_testnet`." - "CoinStats: GET /wallet/blockchains with X-API-KEY." - - "Sim by Dune: GET /v1/evm/supported-chains (no auth) — EVM only, mainnets filtered via the `mainnet` tag." + - "Dune (via Sim API): GET https://api.sim.dune.com/v1/evm/supported-chains (no auth) — EVM only, mainnets filtered via the `mainnet` tag." - "Cadence: full refresh every 6 hours." - "Counting: a provider's network is counted once per unique chain id; mainnet only." - "Failures (network errors, rate limits, auth errors) leave the previous count in place and increment a fetch_errors counter. the page falls back to its last successful sample." @@ -84,7 +84,7 @@ faq: # Real metrics exposed by the network-coverage harness: # networks_supported_total{provider="geckoterminal"|"codex"|"mobula"| # "coinpaprika"|"covalent"| -# "coinstats"|"sim-dune"} +# "coinstats"|"dune"} # -> gauge, the unique-chain count from the latest successful refresh. # network_supported{provider, chain_id, slug, name} -> gauge (1 per # network; useful for diff queries on the site if we add a @@ -169,15 +169,15 @@ providers: sample_size: networks_supported_total{provider="coinstats"} series: networks_supported_total{provider="coinstats"} - - slug: sim-dune - name: Sim by Dune - tag: EVM data API (Dune Sim) + - slug: dune + name: Dune + tag: Onchain analytics + Sim API formula: "Count of EVM chains tagged `mainnet` in Sim by Dune's `/v1/evm/supported-chains` endpoint, refreshed every 6 hours. EVM-only." queries: - p50: networks_supported_total{provider="sim-dune"} - p90: networks_supported_total{provider="sim-dune"} - p99: networks_supported_total{provider="sim-dune"} - mean: networks_supported_total{provider="sim-dune"} - success: clamp_max(networks_supported_total{provider="sim-dune"} > bool 0, 1) - sample_size: networks_supported_total{provider="sim-dune"} - series: networks_supported_total{provider="sim-dune"} + p50: networks_supported_total{provider="dune"} + p90: networks_supported_total{provider="dune"} + p99: networks_supported_total{provider="dune"} + mean: networks_supported_total{provider="dune"} + success: clamp_max(networks_supported_total{provider="dune"} > bool 0, 1) + sample_size: networks_supported_total{provider="dune"} + series: networks_supported_total{provider="dune"} diff --git a/harnesses/network-coverage/cmd/script/coinpaprika.go b/harnesses/network-coverage/cmd/script/coinpaprika.go new file mode 100644 index 00000000..46bd5696 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/coinpaprika.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const coinpaprikaContractsURL = "https://api.coinpaprika.com/v1/contracts" + +// CoinPaprika's /v1/contracts returns a flat array of platform slugs in +// "{symbol}-{name}" form (e.g. "eth-ethereum", "trx-tron"). It is the closest +// thing they expose to a list of "chains supported for contract lookup". +func fetchCoinPaprika(_ *Config) ProviderResult { + res := ProviderResult{Provider: "coinpaprika"} + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", coinpaprikaContractsURL, nil) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var parsed []string + if err := json.Unmarshal(body, &parsed); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, slug := range parsed { + name := slug + if idx := strings.Index(slug, "-"); idx >= 0 && idx+1 < len(slug) { + name = strings.ReplaceAll(slug[idx+1:], "-", " ") + } + res.Networks = append(res.Networks, Network{ + ChainID: "", + Slug: slug, + Name: name, + }) + } + + return res +} diff --git a/harnesses/network-coverage/cmd/script/coinstats.go b/harnesses/network-coverage/cmd/script/coinstats.go new file mode 100644 index 00000000..344c6ee7 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/coinstats.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const coinstatsBlockchainsURL = "https://openapiv1.coinstats.app/wallet/blockchains" + +// CoinStats /wallet/blockchains returns a flat JSON array of: +// {connectionId, name, icon, chain} +// `connectionId` is the slug (e.g. "binancesmartchain"), `chain` is the +// category enum (e.g. "binance_smart"), `name` is the human label. +type coinstatsBlockchain struct { + ConnectionID string `json:"connectionId"` + Name string `json:"name"` + Chain string `json:"chain"` +} + +func fetchCoinStats(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "coinstats"} + if cfg.CoinStatsAPIKey == "" { + res.Err = "missing_api_key" + return res + } + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", coinstatsBlockchainsURL, nil) + req.Header.Set("X-API-KEY", cfg.CoinStatsAPIKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var arr []coinstatsBlockchain + if err := json.Unmarshal(body, &arr); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, b := range arr { + res.Networks = append(res.Networks, Network{ + ChainID: b.Chain, + Slug: b.ConnectionID, + Name: b.Name, + }) + } + return res +} diff --git a/harnesses/network-coverage/cmd/script/config.go b/harnesses/network-coverage/cmd/script/config.go index 0b356452..311ace2b 100644 --- a/harnesses/network-coverage/cmd/script/config.go +++ b/harnesses/network-coverage/cmd/script/config.go @@ -12,6 +12,9 @@ type Config struct { CodexAPIKey string // official Codex Bearer (preferred — no mint, no proxy) CodexSessionCookie string // fallback path: mint JWT from Defined.fi cookie DefinedTokenURL string // optional: pre-minted JWT sidecar + CovalentAPIKey string + CoinStatsAPIKey string + SimDuneAPIKey string // optional — Sim's public endpoint works keyless, but a key avoids rate limits HTTPProxy string RefreshInterval time.Duration IncludeTestnets bool @@ -23,6 +26,9 @@ func loadConfig() *Config { CodexAPIKey: os.Getenv("CODEX_API_KEY"), CodexSessionCookie: os.Getenv("DEFINED_SESSION_COOKIE"), DefinedTokenURL: os.Getenv("DEFINED_TOKEN_SERVICE_URL"), + CovalentAPIKey: os.Getenv("COVALENT_API_KEY"), + CoinStatsAPIKey: os.Getenv("COINSTATS_API_KEY"), + SimDuneAPIKey: os.Getenv("SIM_DUNE_API_KEY"), HTTPProxy: os.Getenv("HTTP_PROXY"), RefreshInterval: 6 * time.Hour, IncludeTestnets: false, @@ -43,7 +49,8 @@ func loadConfig() *Config { } else if c.CodexSessionCookie != "" { codexAuth = "cookie+mint" } - fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s\n", - c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth) + fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, covalent_key=%v, coinstats_key=%v, sim_dune_key=%v\n", + c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth, + c.CovalentAPIKey != "", c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "") return c } diff --git a/harnesses/network-coverage/cmd/script/covalent.go b/harnesses/network-coverage/cmd/script/covalent.go new file mode 100644 index 00000000..740d22b2 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/covalent.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const covalentChainsURL = "https://api.covalenthq.com/v1/chains/" + +// Covalent returns chain_id as a numeric string even for canonical EVM +// chains (e.g. "1" for Ethereum). Keep the raw string to avoid losing +// precision on non-EVM chains where the id can exceed int64 range. +type covalentChain struct { + ChainID string `json:"chain_id"` + Name string `json:"name"` + Label string `json:"label"` + IsTestnet bool `json:"is_testnet"` +} + +type covalentResponse struct { + Data struct { + Items []covalentChain `json:"items"` + } `json:"data"` + Error bool `json:"error"` + ErrorMessage string `json:"error_message"` +} + +func fetchCovalent(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "covalent"} + if cfg.CovalentAPIKey == "" { + res.Err = "missing_api_key" + return res + } + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", covalentChainsURL, nil) + req.Header.Set("Authorization", "Bearer "+cfg.CovalentAPIKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var parsed covalentResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + if parsed.Error { + res.Err = "api_error: " + parsed.ErrorMessage + return res + } + + for _, c := range parsed.Data.Items { + if c.IsTestnet && !cfg.IncludeTestnets { + continue + } + label := c.Label + if label == "" { + label = c.Name + } + res.Networks = append(res.Networks, Network{ + ChainID: c.ChainID, + Slug: c.Name, + Name: label, + Testnet: c.IsTestnet, + }) + } + + return res +} diff --git a/harnesses/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go index 04c7f24f..bcaab8b0 100644 --- a/harnesses/network-coverage/cmd/script/main.go +++ b/harnesses/network-coverage/cmd/script/main.go @@ -71,6 +71,10 @@ func fetchAll(cfg *Config) { {"geckoterminal", fetchGeckoTerminal}, {"mobula", fetchMobula}, {"codex", fetchCodex}, + {"coinpaprika", fetchCoinPaprika}, + {"dune", fetchSimDune}, + {"covalent", fetchCovalent}, + {"coinstats", fetchCoinStats}, } var wg sync.WaitGroup diff --git a/harnesses/network-coverage/cmd/script/simdune.go b/harnesses/network-coverage/cmd/script/simdune.go new file mode 100644 index 00000000..508abf0d --- /dev/null +++ b/harnesses/network-coverage/cmd/script/simdune.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const simDuneChainsURL = "https://api.sim.dune.com/v1/evm/supported-chains" + +type simDuneChain struct { + Name string `json:"name"` + ChainID int `json:"chain_id"` + Tags []string `json:"tags"` +} + +type simDuneResponse struct { + Chains []simDuneChain `json:"chains"` +} + +// fetchSimDune queries Sim by Dune's public supported-chains endpoint. +// EVM-only by design. Mainnets are tagged "mainnet"; everything else is +// treated as testnet/preview and filtered out unless IncludeTestnets is on. +func fetchSimDune(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "dune"} + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", simDuneChainsURL, nil) + req.Header.Set("Accept", "application/json") + if cfg.SimDuneAPIKey != "" { + req.Header.Set("X-Sim-Api-Key", cfg.SimDuneAPIKey) + } + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var parsed simDuneResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, c := range parsed.Chains { + isMainnet := false + for _, t := range c.Tags { + if t == "mainnet" { + isMainnet = true + break + } + } + if !isMainnet && !cfg.IncludeTestnets { + continue + } + res.Networks = append(res.Networks, Network{ + ChainID: strconv.Itoa(c.ChainID), + Slug: c.Name, + Name: c.Name, + Testnet: !isMainnet, + }) + } + + return res +} diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 8b798c82..63dbb8cd 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -76,13 +76,7 @@ export const PROVIDER_REGISTRY: Record = { dune: { url: "https://dune.com", description: - "Onchain analytics platform. SQL-queryable indexed data across EVM chains and Solana, public dashboards, plus the Sim onchain-data API for wallet, transaction, and token endpoints.", - twitter: "@DuneAnalytics", - }, - "sim-dune": { - url: "https://sim.dune.com", - description: - "Onchain-data REST API by Dune. EVM wallet balances, transactions, token info, holders, collectibles, and DeFi positions across 60+ mainnets.", + "Onchain analytics platform. SQL-queryable indexed data across EVM chains and Solana, public dashboards, plus the Sim REST API for wallet balances, transactions, token info, holders, and DeFi positions across 60+ EVM mainnets.", twitter: "@DuneAnalytics", }, coinpaprika: { diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 21ad2408..73fee122 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -165,8 +165,6 @@ const ALIASES: Record = { "avalanche-official": "avalanche", "optimism-official": "optimism", - // Sim by Dune is Dune's onchain-data product — reuses Dune's brand mark. - "sim-dune": "dune", }; export function logoPath(slug: string): string | null { From 3ca6c5b7b102f0885a2993a002d7cc26372b3b40 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 20:40:08 +0300 Subject: [PATCH 014/174] feat(bench-027): add Mobula Swap-Send to solana-tx-landing-latency (#160) * feat(bench-027): add Mobula Swap-Send to solana-tx-landing-latency providers * feat(harnesses): sync solana-tx-landing OSS mirror with Mobula provider --- benchmarks/solana-tx-landing-latency.yml | 17 ++- .../solana-tx-landing/cmd/script/senders.go | 110 ++++++++++++++++++ .../solana-tx-landing/cmd/script/wallets.go | 4 + 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml index 309c8112..25000ebe 100644 --- a/benchmarks/solana-tx-landing-latency.yml +++ b/benchmarks/solana-tx-landing-latency.yml @@ -3,8 +3,8 @@ slug: solana-tx-landing-latency number: "027" title: Solana transaction landing latency -seo_title: "Solana tx landing latency 2026: Jito vs Helius vs Nozomi vs Astralane time-to-land in slots" -seo_description: "Live active benchmark of Solana transaction landing services. Slot-level time-to-land (canonical on-chain measurement) plus wall-clock milliseconds. Identical signed mainnet probes submitted to each service from us-east every hour. Methodology pre-registered." +seo_title: "Solana tx landing latency 2026: Jito vs Helius vs Nozomi vs Astralane vs Mobula time-to-land in slots" +seo_description: "Live active benchmark of Solana transaction landing services. Slot-level time-to-land (canonical on-chain measurement) plus wall-clock milliseconds. Identical signed mainnet probes submitted to each service (Jito, Helius Sender, Astralane, Nozomi, Mobula) from us-east every hour. Methodology pre-registered." subtitle: How fast does each landing service get a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot ≈ 400 ms). Active probing, identical signed txs every hour from us-east. category: Trading status: live @@ -206,3 +206,16 @@ providers: success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} + + - slug: mobula + name: Mobula Swap-Send + tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted via Mobula's `api.mobula.io/api/2/swap/send` (multi-RPC fan-out) from us-east. Tip wallet reuses Jito's because Mobula default-routes to Jito; attribution is via signatureSubscribe, independent of the tip wallet." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/harnesses/solana-tx-landing/cmd/script/senders.go b/harnesses/solana-tx-landing/cmd/script/senders.go index d8f55c9c..e0d88ffd 100644 --- a/harnesses/solana-tx-landing/cmd/script/senders.go +++ b/harnesses/solana-tx-landing/cmd/script/senders.go @@ -87,6 +87,10 @@ func buildProbes(enabled map[Service]bool) ([]serviceProbe, error) { buildNozomiProbe(tipWallets[ServiceNozomi]), buildAstralaneProbe(tipWallets[ServiceAstralane]), buildZeroSlotProbe(tipWallets[Service0slot]), + // Mobula has no own tip wallet (relays via Jito/Nozomi/zeroslot internally). + // Embed a Jito tip transfer so Mobula's default routing sees a valid tip; + // attribution uses signatureSubscribe (active probe path), not tip-wallet. + buildMobulaProbe(tipWallets[ServiceJito]), } out := make([]serviceProbe, 0, len(candidates)) @@ -439,3 +443,109 @@ func truncate(s string, n int) string { } return s[:n] + "…" } + +// ----------------------------------------------------------------------------- +// Mobula swap-send — multi-RPC fan-out aggregator +// docs.mobula.io/rest-api-reference/endpoint/swap-send +// +// Wire shape (verified 2026-05-27): +// - POST https://api.mobula.io/api/2/swap/send +// - Authorization: Bearer +// - Body: {"chainId": "solana:solana", "signedTransaction": ""} +// - Response on success: {"data": {"transactionHash": "", "requestId": "...", +// "success": true, "lander": "jito|nozomi|..."}} +// - 201 returned for accepted submission; downstream failure surfaces inside +// the JSON body via `error` / `data.success=false`. +// +// Mobula relays internally to Jito / Nozomi / zeroslot via multi-RPC fan-out, +// so there is no dedicated Mobula tip wallet. The probe embeds a Jito tip +// transfer (same wallet as the Jito control probe) so Mobula's default +// routing sees a valid tip on the underlying lander. Active-probe attribution +// works via signatureSubscribe on the tx signature, independent of the tip +// wallet used. +// ----------------------------------------------------------------------------- + +func buildMobulaProbe(tip solana.PublicKey) serviceProbe { + key := strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) + if key == "" { + fmt.Println("[prober] mobula: MOBULA_API_KEY not set — service skipped") + return serviceProbe{} + } + url := envDefault("MOBULA_ENDPOINT", "https://api.mobula.io/api/2/swap/send") + tipLamports := uint64(envInt("MOBULA_TIP_LAMPORTS", 10_000)) + + return serviceProbe{ + Service: ServiceMobula, + Mode: "default", + Endpoint: url, + TipWallet: tip, + TipLamports: tipLamports, + Sender: func(ctx context.Context, tx *solana.Transaction) (string, error) { + return postMobulaSwapSend(ctx, url, key, tx) + }, + } +} + +func postMobulaSwapSend(ctx context.Context, url, apiKey string, tx *solana.Transaction) (string, error) { + txB64, err := txToBase64(tx) + if err != nil { + return "", err + } + reqBody := map[string]any{ + "chainId": "solana:solana", + "signedTransaction": txB64, + } + buf, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal req: %w", err) + } + cctx, cancel := context.WithTimeout(ctx, senderHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return "", fmt.Errorf("new req: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := senderHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("http: %s", scrubSecrets(err.Error())) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 419 || resp.StatusCode == 429 { + return "", fmt.Errorf("http %d (rate limit): %s", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + if resp.StatusCode >= 500 { + return "", fmt.Errorf("http %d: %s", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + + var parsed struct { + Data struct { + TransactionHash string `json:"transactionHash"` + Success bool `json:"success"` + } `json:"data"` + Error string `json:"error"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", fmt.Errorf("decode resp (status=%d body=%s): %w", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200)), err) + } + if parsed.Error != "" { + return "", fmt.Errorf("mobula: %s", scrubSecrets(parsed.Error)) + } + if parsed.Data.TransactionHash != "" { + return parsed.Data.TransactionHash, nil + } + // Mobula sometimes returns 201 without echoing the sig. Fall back to + // the locally-computed signature (known once tx.Sign ran). + if len(tx.Signatures) == 0 { + return "", fmt.Errorf("no signature in response or on tx (status=%d body=%s)", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + return tx.Signatures[0].String(), nil +} diff --git a/harnesses/solana-tx-landing/cmd/script/wallets.go b/harnesses/solana-tx-landing/cmd/script/wallets.go index 55db22e8..94e1d995 100644 --- a/harnesses/solana-tx-landing/cmd/script/wallets.go +++ b/harnesses/solana-tx-landing/cmd/script/wallets.go @@ -28,6 +28,10 @@ const ( ServiceNextBlock Service = "nextblock" ServiceAstralane Service = "astralane" ServiceSolanaVibeStation Service = "solanavibestation" + // Mobula swap-send. No dedicated tip wallets — Mobula relays internally + // to Jito/Nozomi/zeroslot via multi-RPC fan-out. Not observable via + // tip-wallet attribution (bench № 016); active-probe-only (bench № 027). + ServiceMobula Service = "mobula" ) // walletToService maps every known tip address to its owning service. From ba06cfbf0bdfd8500b0a560c313f8053bce7cd55 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 20:48:14 +0300 Subject: [PATCH 015/174] fix(bench-027): trim Mobula formula to 240 chars (Zod schema limit) (#161) --- benchmarks/solana-tx-landing-latency.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml index 25000ebe..6cc2d591 100644 --- a/benchmarks/solana-tx-landing-latency.yml +++ b/benchmarks/solana-tx-landing-latency.yml @@ -210,7 +210,7 @@ providers: - slug: mobula name: Mobula Swap-Send tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted via Mobula's `api.mobula.io/api/2/swap/send` (multi-RPC fan-out) from us-east. Tip wallet reuses Jito's because Mobula default-routes to Jito; attribution is via signatureSubscribe, independent of the tip wallet." + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." queries: p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) From 1520fd7bbaa40cefda72bdc44f7acd4349d3d903 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 20:54:43 +0300 Subject: [PATCH 016/174] fix(bench-027): rename Mobula Swap-Send to Mobula in product ledger (#162) --- benchmarks/solana-tx-landing-latency.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml index 6cc2d591..fd6fc92c 100644 --- a/benchmarks/solana-tx-landing-latency.yml +++ b/benchmarks/solana-tx-landing-latency.yml @@ -208,7 +208,7 @@ providers: series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} - slug: mobula - name: Mobula Swap-Send + name: Mobula tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." queries: From 785a16d04c0287e1f893df2bac43e477c6bb99bc Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 21:01:03 +0300 Subject: [PATCH 017/174] feat(products): consolidate alias slugs (helius-sender/avalanche-official/oracle pairs) into one canonical entry (#163) --- src/lib/providers.ts | 87 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/src/lib/providers.ts b/src/lib/providers.ts index a5c9951c..45fad1ac 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -13,6 +13,76 @@ import { getBenchmarks } from "@/data/benchmarks"; import { liveResults } from "@/lib/provider-filters"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; +/** + * Slug consolidation for /products. When the same brand or asset appears + * under multiple slugs across benches (e.g. helius vs helius-sender, + * ethereum vs eth-usd oracle pair, avalanche vs avalanche-official RPC), + * we collapse their appearances into one canonical entry so the products + * leaderboard reads cleanly. + * + * Mirrors the logo-manifest ALIASES set, plus the registry `parent` field + * on sub-products. Kept inline here (not derived from the registry) so the + * aggregator never has to read the registry at build time. + */ +const PRODUCT_ALIASES: Record = { + // Sub-products → parent brand + "helius-sender": "helius", + "publicnode-feehistory": "publicnode", + // Chain official RPC → chain brand + "arbitrum-official": "arbitrum", + "avalanche-official": "avalanche", + "base-official": "base", + "optimism-official": "optimism", + // Oracle pair → underlying chain / asset + "eth-usd": "ethereum", + "sol-usd": "solana", + "bnb-usd": "bnb", + "avax-usd": "avalanche", + "btc-usd": "bitcoin", + "xrp-usd": "xrp", + "ada-usd": "cardano", + "doge-usd": "dogecoin", + "link-usd": "chainlink", + "matic-usd": "polygon", +}; + +/** + * Display name for a canonical slug when the canonical itself has no own + * bench appearance (e.g. bitcoin shows up only via the BTC/USD oracle + * pair). Falls back to title-case of the slug for anything not listed. + */ +const CANONICAL_NAMES: Record = { + bitcoin: "Bitcoin", + ethereum: "Ethereum", + solana: "Solana", + bnb: "BNB", + avalanche: "Avalanche", + xrp: "XRP", + cardano: "Cardano", + dogecoin: "Dogecoin", + chainlink: "Chainlink", + polygon: "Polygon", + helius: "Helius", + publicnode: "PublicNode", + arbitrum: "Arbitrum", + base: "Base", + optimism: "Optimism", +}; + +function titleCaseSlug(s: string): string { + return s + .split(/[-_]/) + .map((w) => (w.length === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1))) + .join(" "); +} + +/** Resolves a slug to its canonical (slug, display name) pair. */ +function canonicalize(slug: string): { slug: string; name: string } { + const lc = slug.toLowerCase(); + const canon = PRODUCT_ALIASES[lc] ?? lc; + return { slug: canon, name: CANONICAL_NAMES[canon] ?? titleCaseSlug(canon) }; +} + export type ProviderAppearance = { benchmark: Pick; result: ProviderResult; @@ -54,9 +124,13 @@ export const getProviders = cache(async (): Promise => { const total = ranked.length; b.results.forEach((r) => { - const key = r.slug.toLowerCase(); + const canon = canonicalize(r.slug); + const key = canon.slug; const existing = byKey.get(key); - const idx = rankBySlug.get(key); + // Ranking is per-bench, so look up the rank by the result's own + // slug (the bench knows it under its original name), not the + // canonical key. + const idx = rankBySlug.get(r.slug.toLowerCase()); const isRanked = idx !== undefined; const appearance: ProviderAppearance = { benchmark: { @@ -83,8 +157,8 @@ export const getProviders = cache(async (): Promise => { if (!existing.type && r.type) existing.type = r.type; } else { byKey.set(key, { - slug: r.slug, - name: r.name, + slug: canon.slug, + name: canon.name, type: r.type, appearances: [appearance], wins: isRanked && idx === 0 ? 1 : 0, @@ -112,5 +186,8 @@ export async function getProviderSlugs(): Promise { export async function getProvider(slug: string): Promise { const profiles = await getProviders(); - return profiles.find((p) => p.slug.toLowerCase() === slug.toLowerCase()); + // Aliased URLs (e.g. /products/helius-sender, /products/btc-usd) resolve + // to the canonical profile so backward-compat links keep working. + const canon = canonicalize(slug).slug; + return profiles.find((p) => p.slug.toLowerCase() === canon); } From 2e749e204f9ff15254ebd63ca44b4b82a1fe35c1 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 27 May 2026 21:06:23 +0300 Subject: [PATCH 018/174] feat(bench): simplify provider display names to brand-only across benches (#164) --- benchmarks/gas-estimation.yml | 2 +- benchmarks/l1-finality.yml | 4 ++-- benchmarks/rpc-capabilities.yml | 18 +++++++++--------- benchmarks/solana-tx-landing-latency.yml | 6 +++--- benchmarks/solana-tx-landing.yml | 8 ++++---- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/benchmarks/gas-estimation.yml b/benchmarks/gas-estimation.yml index 26976493..a1e84a90 100644 --- a/benchmarks/gas-estimation.yml +++ b/benchmarks/gas-estimation.yml @@ -149,7 +149,7 @@ providers: series: gas_error_priority_gwei{oracle="blocknative", tier="p50"} - slug: publicnode-feehistory - name: PublicNode feeHistory + name: PublicNode tag: Thin wrapper over EIP-1559 reward percentiles formula: "Median over 24h of |PublicNode eth_feeHistory p50 reward − realized p50 priority fee| in gwei, computed per next-mined block on the active chain." queries: diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml index f29c78d7..0d72eee1 100644 --- a/benchmarks/l1-finality.yml +++ b/benchmarks/l1-finality.yml @@ -150,7 +150,7 @@ prometheus: providers: - slug: bnb - name: BNB Smart Chain + name: BNB tag: Fast finality fork (BEP-126), measured via WS wall-clock formula: "Median wall-clock milliseconds between a BNB block first appearing as `latest` on WebSocket and crossing the BEP-126 finalized threshold, p50 over 24h." queries: @@ -163,7 +163,7 @@ providers: series: l1_finality_wallclock_lag_milliseconds{chain="bnb"} - slug: avalanche - name: Avalanche C-Chain + name: Avalanche tag: Snowman++ consensus, measured via WS wall-clock formula: "Median wall-clock milliseconds between a C-Chain block emitting as `latest` and as `finalized` on a persistent WebSocket, p50 over 24h." queries: diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index 70f2f578..b4834e7a 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -173,7 +173,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[1h]) - slug: flashbots - name: Flashbots Protect + name: Flashbots tag: Private-mempool RPC, anti-sandwich formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to the Flashbots Protect read proxy." queries: @@ -199,7 +199,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[1h]) - slug: base-official - name: Base Official + name: Base tag: Coinbase-operated, Base mainnet RPC formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Coinbase's `mainnet.base.org` endpoint." queries: @@ -212,7 +212,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="base-official"}[1h]) - slug: binance - name: Binance Official + name: Binance tag: BNB Chain dataseed RPC, Binance-operated formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Binance's `bsc-dataseed1.binance.org` endpoint." queries: @@ -225,7 +225,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="binance"}[1h]) - slug: tenderly - name: Tenderly Gateway + name: Tenderly tag: Multi-chain public gateway, 9 chains, no key formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to `gateway.tenderly.co/public/`." queries: @@ -238,7 +238,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[1h]) - slug: nodies - name: Nodies (POKT) + name: Nodies tag: POKT Network's decentralized public RPC successor, 7+ chains formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Nodies' `*-pokt.nodies.app` endpoint." queries: @@ -251,7 +251,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="nodies"}[1h]) - slug: lava - name: Lava Network + name: Lava tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Lava Network's no-key mesh endpoint." queries: @@ -277,7 +277,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="merkle"}[1h]) - slug: arbitrum-official - name: Arbitrum Official + name: Arbitrum tag: Arbitrum Foundation public RPC, Arbitrum One only formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." queries: @@ -290,7 +290,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[1h]) - slug: optimism-official - name: Optimism Official + name: Optimism tag: Optimism Foundation public RPC, Optimism mainnet only formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to the Optimism Foundation's `mainnet.optimism.io`." queries: @@ -303,7 +303,7 @@ providers: series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[1h]) - slug: avalanche-official - name: Avalanche Official + name: Avalanche tag: Ava Labs C-Chain public RPC, Avalanche C-Chain only formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Ava Labs' `api.avax.network/ext/bc/C/rpc`." queries: diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml index fd6fc92c..c9647c7d 100644 --- a/benchmarks/solana-tx-landing-latency.yml +++ b/benchmarks/solana-tx-landing-latency.yml @@ -156,7 +156,7 @@ prometheus: providers: - slug: jito - name: Jito Block Engine + name: Jito tag: Baseline + control probe; atomic bundles + tip auction since 2022 formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." queries: @@ -169,7 +169,7 @@ providers: series: solana_landing_probe_latency_slots{service="jito",region="us-east"} - slug: helius-sender - name: Helius Sender (swqos_only) + name: Helius tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." queries: @@ -182,7 +182,7 @@ providers: series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} - slug: astralane - name: Astralane Iris + name: Astralane tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." queries: diff --git a/benchmarks/solana-tx-landing.yml b/benchmarks/solana-tx-landing.yml index 19fbbac0..93ed83e8 100644 --- a/benchmarks/solana-tx-landing.yml +++ b/benchmarks/solana-tx-landing.yml @@ -125,7 +125,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="jito"}[5m])) - slug: helius-sender - name: Helius Sender + name: Helius tag: Dual-path fan-out (Jito + SWQoS staked connections), 7 regions formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Helius Sender's 10 documented tip wallets, deduped via 50k-entry LRU." queries: @@ -151,7 +151,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="nozomi"}[5m])) - slug: bloxroute - name: bloXroute Trader API + name: bloXroute tag: Multi-path BDN propagation, institutional flow formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of bloXroute's 17 `bLx`-prefixed tip wallets, deduped via 50k-entry LRU." queries: @@ -164,7 +164,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="bloxroute"}[5m])) - slug: 0slot - name: 0slot.trade + name: 0slot tag: Premium QUIC leader-direct sender formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of 0slot.trade's 10 documented tip wallets, deduped via 50k-entry LRU." queries: @@ -190,7 +190,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="nextblock"}[5m])) - slug: astralane - name: Astralane Iris + name: Astralane tag: Validator co-located, leader-schedule-aware routing formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to Astralane Iris's documented tip wallet, deduped via 50k-entry LRU." queries: From bcaa28be164484c8a4f220bcffd340e60cc5417d Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 28 May 2026 17:27:00 +0200 Subject: [PATCH 019/174] fix(distribution): auto log scale when dynamic range > 50x The distribution view used a linear scale anchored at max(p99). On benches with very wide ranges (l1-finality spans 0.4s on TON to 36 min on Monero, ratio 5370x) every fast row collapsed to a single invisible pixel at the left edge, making 7 of 9 chains visually indistinguishable. Only the two slowest rows had visible bars. Port the same auto-log heuristic ranked-bar-chart already uses: when max/min > 50, switch to base-10 log projection so each row gets a proportional, distinguishable position. Footer flags 'log scale' so the reader knows when it kicks in. Same component is shared by every bench in distribution view, so this fixes l1-finality, l2-block-time, validator-yield, perp-fees and any other bench with wide dynamic range in one shot. --- src/components/distribution-chart.tsx | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index e4a6020a..39e671be 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -57,10 +57,29 @@ export function DistributionChart({ ); // Field scale recomputes from visible rows only - excluding an outlier - // gives the remaining rows the full track width. + // gives the remaining rows the full track width. We take both extremes + // because the auto-log switch needs the dynamic range, not just the max. const visible = sorted.filter((r) => !excluded.has(r.slug)); const fieldMax = Math.max(...visible.map((r) => r.ms.p99), 1); - const scale = (v: number) => Math.max(0, Math.min(100, (v / fieldMax) * 100)); + const fieldMin = Math.min( + ...visible.flatMap((r) => [r.ms.p50, r.ms.p90, r.ms.p99]).filter((v) => v > 0), + fieldMax, + ); + + // Use log scale when dynamic range > 50x. Same threshold as + // ranked-bar-chart for consistency. Critical on benches like l1-finality + // where TON (0.4 s) coexists with Monero (36 min): a linear scale + // collapses every sub-second chain into the same invisible pixel at the + // left, making 8 of 9 rows visually indistinguishable. + const useLog = fieldMax / Math.max(fieldMin, 1) > 50; + + const scale = (v: number) => { + if (v <= 0) return 0; + if (!useLog) return Math.max(0, Math.min(100, (v / fieldMax) * 100)); + const lo = Math.log10(Math.max(1, fieldMin / 2)); + const hi = Math.log10(fieldMax); + return Math.max(0, Math.min(100, ((Math.log10(v) - lo) / (hi - lo)) * 100)); + }; return (

@@ -181,8 +200,10 @@ export function DistributionChart({ })}
- 0 - max {fmtUnit(fieldMax, unit)} + {useLog ? `min ${fmtUnit(fieldMin, unit)}` : "0"} + + {useLog ? "log scale · " : ""}max {fmtUnit(fieldMax, unit)} +
); From 2f55f70d1beda35ad6f1cc7ec7d2d11adc2489b7 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 11:22:34 +0200 Subject: [PATCH 020/174] fix(distribution): anchor left edge at fieldMin, not absolute zero Narrow-range benches with small absolute minimums collapse every median dot into a single sliver at the left. solana-tx-landing-latency is the canonical case: 5 providers all have p50 = 2 slots on a 14-slot field, so every median lands at 14 % of the track and there is no visible spread between the leaders. Switch both linear and log scale to use fieldMin as the left baseline. The leader now sits at 0 % and laggards spread across the full track. Footer always shows 'min X' so the reader knows the chart is relative to observed range, not absolute zero. --- src/components/distribution-chart.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 39e671be..dad4fb88 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -73,12 +73,23 @@ export function DistributionChart({ // left, making 8 of 9 rows visually indistinguishable. const useLog = fieldMax / Math.max(fieldMin, 1) > 50; + // Both paths anchor the left edge at fieldMin (best observed value), + // not at absolute zero. Otherwise narrow-range benches with small mins + // (e.g. solana-tx-landing-latency where every provider's p50 = 2 slots + // on a 14-slot field) compress every median dot into a single sliver + // at the left, making it impossible to read dispersion. With this + // baseline the leader sits at 0 % and laggards spread the full track. const scale = (v: number) => { if (v <= 0) return 0; - if (!useLog) return Math.max(0, Math.min(100, (v / fieldMax) * 100)); - const lo = Math.log10(Math.max(1, fieldMin / 2)); - const hi = Math.log10(fieldMax); - return Math.max(0, Math.min(100, ((Math.log10(v) - lo) / (hi - lo)) * 100)); + if (useLog) { + const lo = Math.log10(Math.max(1, fieldMin / 2)); + const hi = Math.log10(fieldMax); + if (hi <= lo) return 50; + return Math.max(0, Math.min(100, ((Math.log10(v) - lo) / (hi - lo)) * 100)); + } + const range = fieldMax - fieldMin; + if (range <= 0) return 50; + return Math.max(0, Math.min(100, ((v - fieldMin) / range) * 100)); }; return ( @@ -200,7 +211,7 @@ export function DistributionChart({ })}
- {useLog ? `min ${fmtUnit(fieldMin, unit)}` : "0"} + min {fmtUnit(fieldMin, unit)} {useLog ? "log scale · " : ""}max {fmtUnit(fieldMax, unit)} From 4819e5ef15df039aa6d83d2eb5b72f1dd04b7360 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 11:45:10 +0200 Subject: [PATCH 021/174] scrub deploy IDs and team scope from public workflow --- .github/workflows/staging-deploy.yml | 12 +----------- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 65233157..528eec40 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -1,15 +1,5 @@ name: Vercel staging deploy -# Auto-deploy to Vercel Preview on every push to dev. Workaround for the -# Vercel ↔ GitHub App pairing being unreliable across the OpenChainBench -# (user account) + mobula-labs (Vercel team) setup. The Action calls the -# Vercel CLI directly with VERCEL_TOKEN, bypassing the App webhook flow. -# -# Required GitHub secrets (Settings → Secrets and variables → Actions): -# VERCEL_TOKEN — vercel.com/account/tokens, scope = mobula-labs -# VERCEL_ORG_ID — ***SCRUBBED*** -# VERCEL_PROJECT_ID — ***SCRUBBED*** - on: push: branches: @@ -61,7 +51,7 @@ jobs: vercel alias set "${{ steps.deploy.outputs.url }}" \ staging-openchainbench.vercel.app \ --token=${{ secrets.VERCEL_TOKEN }} \ - --scope=mobula-labs + --scope=${{ secrets.VERCEL_ORG_ID }} - name: Comment summary run: | diff --git a/AGENTS.md b/AGENTS.md index d9a6850a..7502e8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This version has breaking changes. APIs, conventions, and file structure may all Two long-lived branches: - `main` → openchainbench.com (production). Vercel auto-deploy on push is **disabled**; production deploys are **manual** via `vercel --prod` from a checkout of `main`. -- `dev` → staging. Each push triggers `.github/workflows/vercel-staging.yml` which calls Vercel CLI with a token and emits a Preview URL. The Vercel GitHub App is NOT used for staging because the OpenChainBench user account + mobula-labs Vercel team pairing wouldn't propagate. +- `dev` → staging. Each push triggers `.github/workflows/staging-deploy.yml` which calls Vercel CLI with a token and emits a Preview URL. **Standard flow:** From f38518b7c375502004efd037ab171709521d9e70 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 12:03:14 +0200 Subject: [PATCH 022/174] feat(bench-001): add GMGN as a provider (Solana-only) + sync harness GMGN's WebSocket pushes Solana chain-tip events (channel=chain_stat, gk='s', rs=ms:slot) sub-second. Adds it as the 4th provider on the head-lag bench. YAML: - New provider entry; queries deliberately omit chain= so per-tab filters auto-hide GMGN on Base/BNB and surface it on All/Solana. - Methodology + cardinality lines updated. Harness sync (matches private mobula-api/miniapps/aggregator-head-lag): - gmgn_monitor.go: chromedp cf_clearance mint + utls HelloChrome_120 ClientHello (h1-only ALPN) + WS subscribe + slot-event parser. - Dockerfile: chromium + minimal X/font libs for chromedp. - go.mod: utls + chromedp deps. - log_buffer.go, config.go, main.go: GMGN_PROXY env + startup banner + goroutine launch. Validated: bun run validate, typecheck, spec tests all green. --- benchmarks/aggregator-head-lag.yml | 38 +- harnesses/aggregator-head-lag/Dockerfile | 16 +- .../aggregator-head-lag/cmd/script/config.go | 2 + .../cmd/script/gmgn_monitor.go | 649 ++++++++++++++++++ .../cmd/script/log_buffer.go | 10 +- .../aggregator-head-lag/cmd/script/main.go | 53 +- harnesses/aggregator-head-lag/go.mod | 4 + harnesses/aggregator-head-lag/go.sum | 6 + harnesses/aggregator-head-lag/script | Bin 0 -> 17718098 bytes 9 files changed, 732 insertions(+), 46 deletions(-) create mode 100644 harnesses/aggregator-head-lag/cmd/script/gmgn_monitor.go create mode 100755 harnesses/aggregator-head-lag/script diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index 9187b13e..eab2f86e 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -36,13 +36,13 @@ abstract: | same event. methodology: - - "Aggregators measured: Mobula, Codex, GeckoTerminal." - - "Chains: Base, BNB Chain, Solana." + - "Aggregators measured: Mobula, Codex, GeckoTerminal, GMGN." + - "Chains: Base, BNB Chain, Solana. GMGN measured Solana-only — its WebSocket pushes per-pair routing events for Solana but only chain-level summaries for EVM chains." - "Regions: us-east, eu-west, sgp. Cross-region median reported in the headline." - - "Reference: archive nodes per chain, validated against block hashes." + - "Reference: archive nodes per chain, validated against block hashes. For GMGN the slot-level `route_info` stream (`gk=\"s\"`) is the canonical observation timestamp." - "Metric: gauge `head_lag_seconds`, sampled every 15 seconds. Aggregated over the 24-hour window using `quantile_over_time`." - "Success rate: presence ratio. Share of expected sampling slots where a value was actually emitted (5,760 expected per provider per day at the 15s cadence). 100% means the aggregator's feed was reachable for the full window." - - "Cardinality: 3 aggregators × 3 chains × 3 regions = 27 active series." + - "Cardinality: 4 aggregators × up to 3 chains × 3 regions, minus inapplicable chains for chain-restricted providers." findings: - "{{best_name}} currently leads head lag at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The number is the wall-clock interval between a swap settling on chain and that swap appearing on the provider's feed, cross-region median." @@ -162,3 +162,33 @@ providers: - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="sgp"}[24h]) * 1000 series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[1h]) * 1000 + + # GMGN is Solana-only. We deliberately leave `chain=` out of the selectors + # so the per-chain dimension filter from lib/spec.ts handles visibility: + # - "All chains" → no chain= injected → GMGN's solana samples returned. + # - "Solana" → chain="solana" injected → returns solana samples. + # - "Base" / "BNB Chain" → chain="base"/"bnb" injected → empty result + # → tryLoadLive skips the provider entirely (see spec.ts ~L477) so + # GMGN is hidden on those tabs instead of showing zeros. + - slug: gmgn + name: GMGN + tag: WebSocket feed (Solana only) + formula: "Median seconds between a Solana slot landing on-chain and GMGN's WebSocket chain-tip stream (chain_stat, gk='s', rs) reflecting it. GMGN doesn't push per-pair events for arbitrary Solana pools, so we measure at the slot level." + queries: + p50: quantile_over_time(0.50, head_lag_seconds{aggregator="gmgn"}[24h]) * 1000 + p90: quantile_over_time(0.90, head_lag_seconds{aggregator="gmgn"}[24h]) * 1000 + p99: quantile_over_time(0.99, head_lag_seconds{aggregator="gmgn"}[24h]) * 1000 + mean: avg_over_time(head_lag_seconds{aggregator="gmgn"}[24h]) * 1000 + success: clamp_max(count_over_time(head_lag_seconds{aggregator="gmgn"}[24h]) / 5760, 1) + sample_size: sum(count_over_time(head_lag_seconds{aggregator="gmgn"}[24h])) + series: avg_over_time(head_lag_seconds{aggregator="gmgn"}[1h]) * 1000 + regions: + - region: us-east + p50: quantile_over_time(0.50, head_lag_seconds{aggregator="gmgn", region="us-east"}[24h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="gmgn", region="us-east"}[1h]) * 1000 + - region: eu-west + p50: quantile_over_time(0.50, head_lag_seconds{aggregator="gmgn", region="eu-west"}[24h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="gmgn", region="eu-west"}[1h]) * 1000 + - region: ap-southeast + p50: quantile_over_time(0.50, head_lag_seconds{aggregator="gmgn", region="sgp"}[24h]) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="gmgn", region="sgp"}[1h]) * 1000 diff --git a/harnesses/aggregator-head-lag/Dockerfile b/harnesses/aggregator-head-lag/Dockerfile index 74ca8f0f..5992cf50 100644 --- a/harnesses/aggregator-head-lag/Dockerfile +++ b/harnesses/aggregator-head-lag/Dockerfile @@ -21,11 +21,25 @@ FROM debian:bookworm-slim WORKDIR /app -# Install runtime dependencies +# Install runtime dependencies. +# +# chromium + minimal X / font libs are required for chromedp, which the +# GMGN.ai head-lag monitor uses to mint Cloudflare cf_clearance cookies +# before dialing wss://gmgn.ai/ws. When GMGN_ENABLED != true the binary +# skips that monitor and Chromium stays idle — the only cost is image +# size (~200MB extra). RUN apt-get update && apt-get install -y \ ca-certificates \ + chromium \ + fonts-liberation \ + libnss3 \ + libgbm1 \ + libasound2 \ && rm -rf /var/lib/apt/lists/* +# Point chromedp at the chromium binary debian ships at /usr/bin/chromium. +ENV CHROME_PATH=/usr/bin/chromium + # Copy binary from builder COPY --from=builder /app/monitor /app/monitor diff --git a/harnesses/aggregator-head-lag/cmd/script/config.go b/harnesses/aggregator-head-lag/cmd/script/config.go index a3fd5114..066ecf1c 100644 --- a/harnesses/aggregator-head-lag/cmd/script/config.go +++ b/harnesses/aggregator-head-lag/cmd/script/config.go @@ -13,6 +13,7 @@ type Config struct { DefinedSessionCookie string MonitorRegion string // Deployment region: us-west, us-east, singapore, etc. MobulaWSURL string // Mobula fast-trade WebSocket endpoint (allows staging to use EU-specific cluster) + GMGNEnabled bool // GMGN.ai monitor enabled (Solana per-pair head-lag). Requires Chrome installed for chromedp. } func loadEnv() (*Config, error) { @@ -24,6 +25,7 @@ func loadEnv() (*Config, error) { config.DefinedSessionCookie = strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE")) config.MonitorRegion = strings.TrimSpace(os.Getenv("MONITOR_REGION")) config.MobulaWSURL = strings.TrimSpace(os.Getenv("MOBULA_WS_URL")) + config.GMGNEnabled = strings.EqualFold(strings.TrimSpace(os.Getenv("GMGN_ENABLED")), "true") // Default to "unknown" if not set if config.MonitorRegion == "" { diff --git a/harnesses/aggregator-head-lag/cmd/script/gmgn_monitor.go b/harnesses/aggregator-head-lag/cmd/script/gmgn_monitor.go new file mode 100644 index 00000000..b2f54faf --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/gmgn_monitor.go @@ -0,0 +1,649 @@ +package main + +// GMGN.ai head-lag monitor — Solana only. +// +// Why Solana only: GMGN's WebSocket pushes per-pair (gk-tagged) `route_info` +// events ONLY for Solana. On Base/BSC/ETH the same `chain_stat` channel +// returns chain-level gas + coin_price but no per-pool real-time stream. +// Confirmed via devtools sniffing of gmgn.ai's own UI: their EVM chain +// pages use REST polling, not WebSocket per-pair pushes. +// +// Pipeline: +// 1. chromedp opens Chrome, navigates to https://gmgn.ai/?chain=sol, +// lets Cloudflare clear, harvests cookies (cf_clearance + __cf_bm) +// AND the live wss://gmgn.ai/ws?... URL the page would open. +// 2. A background goroutine refreshes the session every 25 min +// (cf_clearance TTL is 30-45 min). +// 3. gorilla/websocket dials the captured WS URL via getProxyDialer() +// so HTTP_PROXY/HTTPS_PROXY routes the WS through the same +// residential proxy as Codex / GeckoTerminal. +// 4. We subscribe `chain_stat` on chain="sol" and filter incoming +// `route_info` events where gk == bench Solana pool address. +// 5. Each event embeds `::` in d.p / d.a / d.b. +// We use unix_ms (GMGN's pipeline observation timestamp, ms-precision) +// as the anchor and compute head_lag = receivedAt - unix_ms. This is +// what the bench measures for the other providers too — wall-clock +// gap between the canonical observation moment and WebSocket receipt. +// +// Heartbeat: GMGN expects `{"action":"heartbeat","client_ts":}` +// ~every 30s; we send every 25s. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chromedp/cdproto/fetch" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" + "github.com/gorilla/websocket" + utls "github.com/refraction-networking/utls" +) + +// GMGN monitors the same Solana pool as the rest of the head-lag bench so +// the leaderboard stays apples-to-apples. Address picked from headLagPools +// (head_lag_monitor.go) at runtime. +const gmgnChain = "sol" + +// gmgnSession is the live cookie + WS URL bundle. Refreshed every 25 min; +// readers see a coherent snapshot via the atomic pointer. +type gmgnSession struct { + WSURL string + CookieHeader string + UserAgent string + MintedAt time.Time +} + +var gmgnSessionPtr atomic.Pointer[gmgnSession] + +func runGMGNHeadLagMonitor(config *Config, stopChan <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + + // Print unconditionally so /logs proves the goroutine actually fired. + fmt.Println("[HEAD-LAG][GMGN] goroutine entered") + + if !config.GMGNEnabled { + fmt.Println("[HEAD-LAG][GMGN] disabled (GMGN_ENABLED != true) — set GMGN_ENABLED=true to enable") + return + } + fmt.Println("[HEAD-LAG][GMGN] starting Solana per-pair monitor…") + + var solPool HeadLagPool + for _, p := range headLagPools { + if p.ChainName == "solana" { + solPool = p + break + } + } + if solPool.Address == "" { + log.Println("[HEAD-LAG][GMGN] no Solana pool in headLagPools, aborting") + return + } + fmt.Printf("[HEAD-LAG][GMGN] target pool: %s (%s) region=%s\n", + solPool.Name, solPool.Address, config.MonitorRegion) + + // Initial cookie mint. Don't return on failure — the reconnect loop will + // keep retrying and the refresher goroutine will keep trying every 25 min. + fmt.Printf("[HEAD-LAG][GMGN] minting initial cf_clearance via chromedp (chrome at %s)…\n", + os.Getenv("CHROME_PATH")) + if err := gmgnRefreshSession(stopChan); err != nil { + fmt.Printf("[HEAD-LAG][GMGN] initial cookie mint failed: %v — will retry\n", err) + } else { + fmt.Printf("[HEAD-LAG][GMGN] initial cookie mint OK\n") + } + + // Periodic cookie refresh. + refreshWG := &sync.WaitGroup{} + refreshWG.Add(1) + go func() { + defer refreshWG.Done() + gmgnSessionRefresher(stopChan) + }() + + // Reconnect loop. + reconnect := 5 * time.Second + const reconnectMax = 60 * time.Second + failures := 0 + for { + select { + case <-stopChan: + refreshWG.Wait() + return + default: + } + + err := gmgnConnectAndConsume(config, solPool, stopChan) + if err != nil { + failures++ + fmt.Printf("[HEAD-LAG][GMGN] connection error: %v — reconnect in %v (failures=%d)\n", + err, reconnect, failures) + select { + case <-stopChan: + refreshWG.Wait() + return + case <-time.After(reconnect): + if reconnect < reconnectMax { + reconnect *= 2 + if reconnect > reconnectMax { + reconnect = reconnectMax + } + } + } + // After 5 consecutive failures, force a fresh cookie mint — + // the WS may be rejecting our stale cf_clearance / IP combo. + if failures%5 == 0 { + fmt.Printf("[HEAD-LAG][GMGN] %d consecutive failures, force cookie refresh\n", failures) + if refreshErr := gmgnRefreshSession(stopChan); refreshErr != nil { + fmt.Printf("[HEAD-LAG][GMGN] force cookie refresh failed: %v\n", refreshErr) + } + } + continue + } + reconnect = 5 * time.Second + failures = 0 + } +} + +func gmgnSessionRefresher(stopChan <-chan struct{}) { + ticker := time.NewTicker(25 * time.Minute) + defer ticker.Stop() + for { + select { + case <-stopChan: + return + case <-ticker.C: + if err := gmgnRefreshSession(stopChan); err != nil { + fmt.Printf("[HEAD-LAG][GMGN] periodic cookie refresh failed: %v\n", err) + } + } + } +} + +// gmgnRefreshSession runs chromedp once, harvests cookies + WS URL, and +// atomically swaps the global session pointer. +func gmgnRefreshSession(stopChan <-chan struct{}) error { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + go func() { + select { + case <-stopChan: + cancel() + case <-ctx.Done(): + } + }() + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("disable-blink-features", "AutomationControlled"), + chromedp.WindowSize(1280, 800), + chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), + ) + // chromium rejects --proxy-server URLs that embed user:pass with + // ERR_NO_SUPPORTED_PROXIES, so we strip credentials and re-inject + // them via the CDP Fetch.authRequired handler below. + proxyURL, proxyUser, proxyPass := gmgnProxyParts() + if proxyURL != "" { + opts = append(opts, chromedp.ProxyServer(proxyURL)) + fmt.Printf("[HEAD-LAG][GMGN] using proxy %s (auth=%v)\n", proxyURL, proxyUser != "") + } else { + // chromium silently falls back to $HTTP_PROXY when --proxy-server is + // absent, which would re-use the rotating proxy's embedded creds and + // fail with ERR_INVALID_AUTH_CREDENTIALS. Force a direct connection. + opts = append(opts, chromedp.ProxyServer("direct://")) + fmt.Println("[HEAD-LAG][GMGN] no proxy (direct connection, GMGN_PROXY=none)") + } + allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, opts...) + defer cancelAlloc() + + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + + var ( + wsMu sync.Mutex + wsURLs []string + ) + chromedp.ListenTarget(browserCtx, func(ev interface{}) { + if e, ok := ev.(*network.EventWebSocketCreated); ok { + wsMu.Lock() + wsURLs = append(wsURLs, e.URL) + wsMu.Unlock() + return + } + if e, ok := ev.(*fetch.EventAuthRequired); ok && proxyUser != "" { + go func() { + _ = chromedp.Run(browserCtx, + fetch.ContinueWithAuth(e.RequestID, &fetch.AuthChallengeResponse{ + Response: fetch.AuthChallengeResponseResponseProvideCredentials, + Username: proxyUser, + Password: proxyPass, + }), + ) + }() + return + } + if e, ok := ev.(*fetch.EventRequestPaused); ok { + go func() { + _ = chromedp.Run(browserCtx, fetch.ContinueRequest(e.RequestID)) + }() + } + }) + + var cookies []*network.Cookie + var ua string + runSteps := []chromedp.Action{} + if proxyUser != "" { + // Enable Fetch with auth handling so EventAuthRequired fires for + // the 407 challenge our rotating residential proxy issues. + runSteps = append(runSteps, fetch.Enable().WithHandleAuthRequests(true)) + } + runSteps = append(runSteps, + network.Enable(), + chromedp.Navigate("https://gmgn.ai/?chain=sol"), + chromedp.WaitVisible("body", chromedp.ByQuery), + chromedp.Sleep(6*time.Second), + chromedp.Evaluate(`navigator.userAgent`, &ua), + chromedp.ActionFunc(func(ctx context.Context) error { + cs, err := network.GetCookies().Do(ctx) + if err != nil { + return err + } + cookies = cs + return nil + }), + ) + err := chromedp.Run(browserCtx, runSteps...) + if err != nil { + return fmt.Errorf("chromedp: %w", err) + } + + var parts []string + for _, c := range cookies { + if !strings.HasSuffix(c.Domain, "gmgn.ai") { + continue + } + parts = append(parts, c.Name+"="+c.Value) + } + if len(parts) == 0 { + return fmt.Errorf("no gmgn.ai cookies harvested") + } + + wsMu.Lock() + var wsURL string + for _, u := range wsURLs { + if strings.HasPrefix(u, "wss://gmgn.ai/ws") { + wsURL = u + break + } + } + wsMu.Unlock() + if wsURL == "" { + return fmt.Errorf("page did not open the expected wss://gmgn.ai/ws URL") + } + + gmgnSessionPtr.Store(&gmgnSession{ + WSURL: wsURL, + CookieHeader: strings.Join(parts, "; "), + UserAgent: ua, + MintedAt: time.Now().UTC(), + }) + fmt.Printf("[HEAD-LAG][GMGN] cookie minted (%d cookies, ws=%s)\n", len(parts), wsURL) + return nil +} + +// gmgnDialer returns a websocket dialer that respects the same proxy +// choice as the chromedp mint (via GMGN_PROXY override). When the mint +// uses no proxy, the dial must also use no proxy so Cloudflare sees the +// same source IP that the cf_clearance cookie is bound to. +// +// Cloudflare on gmgn.ai 403s connections whose TLS ClientHello doesn't +// match a real browser (JA3 fingerprint), so we hand the TLS handshake +// off to utls with HelloChrome_120. EnableCompression matches Chrome's +// permessage-deflate WS extension advertisement. +func gmgnDialer() *websocket.Dialer { + dialer := &websocket.Dialer{ + NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + d := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: -1} + return d.DialContext(ctx, network, addr) + }, + NetDialTLSContext: gmgnTLSDial, + HandshakeTimeout: 30 * time.Second, + EnableCompression: true, + } + cleanURL, user, pass := gmgnProxyParts() + if cleanURL == "" { + return dialer + } + u, err := url.Parse(cleanURL) + if err != nil { + return dialer + } + if user != "" { + u.User = url.UserPassword(user, pass) + } + dialer.Proxy = http.ProxyURL(u) + return dialer +} + +// gmgnTLSDial dials TCP then performs the TLS handshake using utls with +// a Chrome 120 ClientHello so the JA3 fingerprint Cloudflare sees matches +// a real browser. Without this, gmgn.ai 403s the WS upgrade despite the +// chromedp-minted cf_clearance cookie being valid. +// +// The Chrome 120 preset advertises ALPN [h2, http/1.1]. WS upgrade is +// HTTP/1.1-only — if the server picks h2 we read back a SETTINGS frame +// and gorilla errors with "malformed HTTP response". Strip h2 from the +// ALPN extension while keeping every other byte of the Chrome ClientHello +// intact (JA3 ignores ALPN payload so the fingerprint stays Chrome). +func gmgnTLSDial(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + d := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: -1} + rawConn, err := d.DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + uConn := utls.UClient(rawConn, &utls.Config{ServerName: host}, utls.HelloCustom) + spec, err := utls.UTLSIdToSpec(utls.HelloChrome_120) + if err != nil { + _ = rawConn.Close() + return nil, fmt.Errorf("utls spec: %w", err) + } + for _, ext := range spec.Extensions { + if alpn, ok := ext.(*utls.ALPNExtension); ok { + alpn.AlpnProtocols = []string{"http/1.1"} + } + } + if err := uConn.ApplyPreset(&spec); err != nil { + _ = rawConn.Close() + return nil, fmt.Errorf("utls apply preset: %w", err) + } + if err := uConn.HandshakeContext(ctx); err != nil { + _ = rawConn.Close() + return nil, fmt.Errorf("utls handshake: %w", err) + } + return uConn, nil +} + +// gmgnProxyParts returns the proxy URL stripped of any user:pass +// credentials, plus the username/password to inject later via the CDP +// Fetch.authRequired handler. chromium's --proxy-server flag rejects +// embedded credentials with ERR_NO_SUPPORTED_PROXIES. +// +// Cloudflare on gmgn.ai binds cf_clearance to the IP that minted it. +// Webshare's rotating endpoint assigns a fresh IP per TCP connection, +// so the cookie minted by chromedp is rejected on the subsequent +// gorilla WS dial (403 bad handshake). GMGN_PROXY lets us override: +// - unset → use HTTP_PROXY / HTTPS_PROXY (existing behavior) +// - "none" → skip proxy entirely (mint + dial from Railway IP) +// - "" → use this URL (e.g. a sticky-session Webshare endpoint) +func gmgnProxyParts() (cleanURL, user, pass string) { + override := strings.TrimSpace(os.Getenv("GMGN_PROXY")) + if strings.EqualFold(override, "none") { + return "", "", "" + } + raw := override + if raw == "" { + raw = strings.TrimSpace(os.Getenv("HTTP_PROXY")) + } + if raw == "" { + raw = strings.TrimSpace(os.Getenv("HTTPS_PROXY")) + } + if raw == "" { + return "", "", "" + } + u, err := url.Parse(raw) + if err != nil { + return raw, "", "" + } + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + u.User = nil + } + return u.String(), user, pass +} + +func gmgnConnectAndConsume(config *Config, pool HeadLagPool, stopChan <-chan struct{}) error { + sess := gmgnSessionPtr.Load() + if sess == nil { + return fmt.Errorf("no session yet (initial mint pending)") + } + + headers := http.Header{} + headers.Set("Origin", "https://gmgn.ai") + headers.Set("User-Agent", sess.UserAgent) + headers.Set("Cache-Control", "no-cache") + headers.Set("Pragma", "no-cache") + headers.Set("Accept-Language", "en-US,en;q=0.9") + headers.Set("Accept", "*/*") + headers.Set("Sec-Fetch-Dest", "websocket") + headers.Set("Sec-Fetch-Mode", "websocket") + headers.Set("Sec-Fetch-Site", "same-origin") + headers.Set("Cookie", sess.CookieHeader) + + dialer := gmgnDialer() + conn, resp, err := dialer.Dial(sess.WSURL, headers) + if err != nil { + status := 0 + var body, cfRay, cfMitigated, server string + if resp != nil { + status = resp.StatusCode + cfRay = resp.Header.Get("Cf-Ray") + cfMitigated = resp.Header.Get("Cf-Mitigated") + server = resp.Header.Get("Server") + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + _ = resp.Body.Close() + body = strings.TrimSpace(strings.ReplaceAll(string(b), "\n", " ")) + } + fmt.Printf("[HEAD-LAG][GMGN] dial 403 details: server=%q cf-ray=%q cf-mitigated=%q body=%.200q\n", + server, cfRay, cfMitigated, body) + return fmt.Errorf("dial failed (http=%d): %w", status, err) + } + defer conn.Close() + fmt.Printf("[HEAD-LAG][GMGN] WS connected, subscribing chain_stat for chain=%s\n", gmgnChain) + + subBody, _ := json.Marshal(map[string]any{ + "action": "subscribe", + "channel": "chain_stat", + "f": "w", + "id": gmgnRandHex(16), + "data": []map[string]string{{"chain": gmgnChain}}, + }) + if err := conn.WriteMessage(websocket.TextMessage, subBody); err != nil { + return fmt.Errorf("subscribe write: %w", err) + } + + hbDone := make(chan struct{}) + defer close(hbDone) + var writeMu sync.Mutex + go func() { + t := time.NewTicker(25 * time.Second) + defer t.Stop() + for { + select { + case <-hbDone: + return + case <-t.C: + hb, _ := json.Marshal(map[string]any{ + "action": "heartbeat", + "client_ts": time.Now().UnixMilli(), + }) + writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _ = conn.WriteMessage(websocket.TextMessage, hb) + writeMu.Unlock() + } + } + }() + + conn.SetPingHandler(func(appData string) error { + writeMu.Lock() + defer writeMu.Unlock() + return conn.WriteControl(websocket.PongMessage, []byte(appData), + time.Now().Add(time.Second)) + }) + + var msgCount int + var lastStatLog time.Time + channelCounts := map[string]int{} + for { + select { + case <-stopChan: + return nil + default: + } + _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + mt, data, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + if mt != websocket.TextMessage { + continue + } + receivedAt := time.Now().UTC() + msgCount++ + + if string(data) == "ping" { + writeMu.Lock() + _ = conn.WriteMessage(websocket.TextMessage, []byte("pong")) + writeMu.Unlock() + continue + } + + // Log the first 10 frames raw so we can see what's actually coming + // over the wire (channel, t, gk fields). Truncated to 300 chars. + if msgCount <= 10 { + preview := string(data) + if len(preview) > 300 { + preview = preview[:300] + "...(truncated)" + } + fmt.Printf("[HEAD-LAG][GMGN] sample msg #%d: %s\n", msgCount, preview) + } + + // Per-channel summary every 30s so we can see the traffic mix + // without spamming the log. + var env gmgnFrame + if json.Unmarshal(data, &env) == nil { + channelCounts[env.Channel]++ + } + if time.Since(lastStatLog) > 30*time.Second { + fmt.Printf("[HEAD-LAG][GMGN] frames=%d channels=%v target_pool=%s\n", + msgCount, channelCounts, pool.Address) + lastStatLog = time.Now() + } + + gmgnHandleFrame(data, receivedAt, pool, config.MonitorRegion) + } +} + +type gmgnFrame struct { + Channel string `json:"channel"` + Data []json.RawMessage `json:"data"` +} + +type gmgnRouteInfo struct { + T string `json:"t"` + TS int64 `json:"ts"` + C string `json:"c"` + GK string `json:"gk"` + D map[string]json.RawMessage `json:"d"` +} + +func gmgnHandleFrame(data []byte, receivedAt time.Time, pool HeadLagPool, region string) { + var env gmgnFrame + if err := json.Unmarshal(data, &env); err != nil { + return + } + if env.Channel != "chain_stat" { + return + } + for _, raw := range env.Data { + var ri gmgnRouteInfo + if err := json.Unmarshal(raw, &ri); err != nil { + continue + } + // GMGN does NOT push per-pair route_info for arbitrary Solana + // pools — verified by 90s isolated sniff: only gk="s" (slot + // updates) and one preset pool (58oQ...) ever appear. Track + // gk="s" so the metric reflects GMGN's view of the Solana tip + // (head-lag at the slot level), which is the apples-to-apples + // comparable to other aggregators' chain-tip tracking. + if ri.T != "route_info" || ri.C != gmgnChain || ri.GK != "s" { + continue + } + obsMs, slot, ok := gmgnExtractAnchor(ri.D) + if !ok { + continue + } + gmgnEmitLag(pool, obsMs, slot, receivedAt, region) + } +} + +// gmgnExtractAnchor returns (unix_ms, slot) from the first parseable +// sub-field. For gk="s" frames: `rs` = `:`, `sc` = `::`. +// `rbh` looks like `:::` so its parts[1] isn't numeric +// and falls through naturally (ParseUint fails, loop continues). +func gmgnExtractAnchor(d map[string]json.RawMessage) (int64, uint64, bool) { + for _, k := range []string{"rs", "sc", "p", "a", "b", "rbh"} { + raw, ok := d[k] + if !ok { + continue + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + continue + } + parts := strings.SplitN(s, ":", 3) + if len(parts) < 2 { + continue + } + obs, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + continue + } + slot, err := strconv.ParseUint(parts[1], 10, 64) + if err != nil { + continue + } + return obs, slot, true + } + return 0, 0, false +} + +var gmgnEmitCount atomic.Int64 +var gmgnLastEmitLog atomic.Int64 + +func gmgnEmitLag(pool HeadLagPool, obsMs int64, slot uint64, receivedAt time.Time, region string) { + obsTime := time.UnixMilli(obsMs).UTC() + lagSeconds := receivedAt.Sub(obsTime).Seconds() + RecordHeadLag("gmgn", pool.ChainName, int64(slot), lagSeconds, region, "") + n := gmgnEmitCount.Add(1) + now := time.Now().Unix() + if now-gmgnLastEmitLog.Load() >= 10 { + gmgnLastEmitLog.Store(now) + fmt.Printf("[HEAD-LAG][GMGN] emit #%d: slot=%d lag=%.3fs chain=%s region=%s\n", + n, slot, lagSeconds, pool.ChainName, region) + } +} + +func gmgnRandHex(n int) string { + const hex = "0123456789abcdef" + b := make([]byte, n) + for i := range b { + b[i] = hex[rand.Intn(16)] + } + return string(b) +} diff --git a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go b/harnesses/aggregator-head-lag/cmd/script/log_buffer.go index 0f7455e0..a0cce9a2 100644 --- a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go +++ b/harnesses/aggregator-head-lag/cmd/script/log_buffer.go @@ -79,12 +79,16 @@ func installLogCapture() { } // setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). -// If LOGS_TOKEN env var is set, requires header `X-Logs-Token` to match. -// Otherwise the endpoint is open — only safe for Railway-internal access. +// Fail-secure: when LOGS_TOKEN env var is not set, the endpoint returns 404 +// (refuses by default). When set, requires header `X-Logs-Token` to match. func setupLogsEndpoint(mux *http.ServeMux) { expectedToken := os.Getenv("LOGS_TOKEN") mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if expectedToken != "" && r.Header.Get("X-Logs-Token") != expectedToken { + if expectedToken == "" { + http.NotFound(w, r) + return + } + if r.Header.Get("X-Logs-Token") != expectedToken { http.Error(w, "forbidden", http.StatusForbidden) return } diff --git a/harnesses/aggregator-head-lag/cmd/script/main.go b/harnesses/aggregator-head-lag/cmd/script/main.go index c0a7eaa7..1c12769e 100644 --- a/harnesses/aggregator-head-lag/cmd/script/main.go +++ b/harnesses/aggregator-head-lag/cmd/script/main.go @@ -30,6 +30,11 @@ func main() { } fmt.Println("Metrics will be exposed on :2112/metrics for Prometheus") + // GMGN integration status — printed unconditionally at startup so we can + // confirm via /logs whether the binary we deployed actually contains the + // GMGN monitor. + fmt.Printf("[GMGN-BUILD] integration v1 present | GMGN_ENABLED=%v | HTTP_PROXY=%v\n", + config.GMGNEnabled, os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "") fmt.Println() sigChan := make(chan os.Signal, 1) @@ -47,56 +52,28 @@ func main() { } }() - // Mobula Pulse V2 feeder — only feeds the metadata coverage queue, - // no pulse-specific metrics emitted (see mobula_pulse_monitor.go). - wg.Add(1) - go func() { - defer wg.Done() - runMobulaPulseMonitor(config, stopChan) - }() - - // Mobula REST API monitor - wg.Add(1) - go func() { - defer wg.Done() - runMobulaRESTMonitor(config, stopChan) - }() - - // Codex REST API monitor - wg.Add(1) - go func() { - defer wg.Done() - runCodexRESTMonitor(config, stopChan) - }() - - // Quote API latency monitor (Jupiter, Li.Fi, 1inch, KyberSwap) - wg.Add(1) - go func() { - defer wg.Done() - runQuoteAPIMonitor(config, stopChan) - }() - - // Metadata coverage monitor (Mobula vs Codex) - wg.Add(1) - go func() { - defer wg.Done() - runMetadataCoverageMonitor(config, stopChan) - }() - - // Head lag monitor (blockchain head vs aggregator indexed head) + // Head lag monitor — the only data producer for this binary. + // Measures the gap between an on-chain Swap event and its appearance + // on each aggregator's WebSocket / REST feed. wg.Add(1) go func() { defer wg.Done() runHeadLagMonitor(config, stopChan) }() - // Mobula Fast-Trade monitor (for comparison with Pulse V2) + // Mobula Fast-Trade monitor — used for the detailed Mobula breakdown + // (processing vs network lag) emitted alongside head lag. wg.Add(1) go func() { defer wg.Done() runMobulaFastTradeMonitor(config, stopChan) }() + // GMGN.ai head-lag monitor (Solana only — gated by GMGN_ENABLED env). + fmt.Println("[GMGN-BUILD] launching GMGN goroutine…") + wg.Add(1) + go runGMGNHeadLagMonitor(config, stopChan, &wg) + <-sigChan fmt.Println("\n\nShutting down monitors...") close(stopChan) diff --git a/harnesses/aggregator-head-lag/go.mod b/harnesses/aggregator-head-lag/go.mod index ed17b543..f7d3a0d1 100644 --- a/harnesses/aggregator-head-lag/go.mod +++ b/harnesses/aggregator-head-lag/go.mod @@ -7,9 +7,11 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/gorilla/websocket v1.5.3 github.com/prometheus/client_golang v1.23.2 + github.com/refraction-networking/utls v1.8.2 ) require ( + github.com/andybalholm/brotli v1.0.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect @@ -17,12 +19,14 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/sys v0.35.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/harnesses/aggregator-head-lag/go.sum b/harnesses/aggregator-head-lag/go.sum index f594bc89..6379815d 100644 --- a/harnesses/aggregator-head-lag/go.sum +++ b/harnesses/aggregator-head-lag/go.sum @@ -1,3 +1,5 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -47,6 +49,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -55,6 +59,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/harnesses/aggregator-head-lag/script b/harnesses/aggregator-head-lag/script new file mode 100755 index 0000000000000000000000000000000000000000..8e30f589ab8c892c0bdf626ff3f27edf56795d3c GIT binary patch literal 17718098 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

## How It Works diff --git a/harnesses/metadata-coverage/alertmanager/alertmanager.yml b/harnesses/metadata-coverage/alertmanager/alertmanager.yml index 6ea7ebaa..c50fd32f 100644 --- a/harnesses/metadata-coverage/alertmanager/alertmanager.yml +++ b/harnesses/metadata-coverage/alertmanager/alertmanager.yml @@ -9,9 +9,13 @@ route: receiver: 'slack-webhook' receivers: + # Replace with your own Slack-bridge webhook URL before deploying. + # In your alertmanager container's entrypoint, run + # envsubst < alertmanager.yml.tpl > alertmanager.yml + # so the value comes from the SLACK_WEBHOOK_URL env var, not the repo. - name: 'slack-webhook' webhook_configs: - - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + - url: 'https://example.invalid/your-slack-webhook' send_resolved: true inhibit_rules: diff --git a/harnesses/metadata-coverage/monitoring/alertmanager.yml b/harnesses/metadata-coverage/monitoring/alertmanager.yml index 77b9e263..c6a4492f 100644 --- a/harnesses/metadata-coverage/monitoring/alertmanager.yml +++ b/harnesses/metadata-coverage/monitoring/alertmanager.yml @@ -9,9 +9,13 @@ route: receiver: 'slack-webhook' receivers: + # Replace with your own Slack-bridge webhook URL before deploying. + # In your alertmanager container's entrypoint, run + # envsubst < alertmanager.yml.tpl > alertmanager.yml + # so the value comes from the SLACK_WEBHOOK_URL env var, not the repo. - name: 'slack-webhook' webhook_configs: - - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + - url: 'https://example.invalid/your-slack-webhook' send_resolved: true inhibit_rules: diff --git a/harnesses/metadata-coverage/prometheus/alertmanager.yml b/harnesses/metadata-coverage/prometheus/alertmanager.yml index 77b9e263..c6a4492f 100644 --- a/harnesses/metadata-coverage/prometheus/alertmanager.yml +++ b/harnesses/metadata-coverage/prometheus/alertmanager.yml @@ -9,9 +9,13 @@ route: receiver: 'slack-webhook' receivers: + # Replace with your own Slack-bridge webhook URL before deploying. + # In your alertmanager container's entrypoint, run + # envsubst < alertmanager.yml.tpl > alertmanager.yml + # so the value comes from the SLACK_WEBHOOK_URL env var, not the repo. - name: 'slack-webhook' webhook_configs: - - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + - url: 'https://example.invalid/your-slack-webhook' send_resolved: true inhibit_rules: From 6f1b5874773115cf8076026a7ced1c3e6bd3629f Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 12:13:09 +0200 Subject: [PATCH 025/174] remove alerting from metadata-coverage harness --- .../metadata-coverage/alertmanager/Dockerfile | 12 - .../alertmanager/alertmanager.yml | 40 ---- .../metadata-coverage/docker-compose.yml | 15 -- .../monitoring/alert_rules.yml | 127 ---------- .../monitoring/alertmanager.yml | 40 ---- .../monitoring/prometheus.yml | 10 - .../metadata-coverage/prometheus/Dockerfile | 4 +- .../prometheus/alert_rules.yml | 223 ------------------ .../prometheus/alertmanager.yml | 40 ---- .../prometheus/prometheus.staging.yml | 13 - .../prometheus/prometheus.yml | 14 -- 11 files changed, 1 insertion(+), 537 deletions(-) delete mode 100644 harnesses/metadata-coverage/alertmanager/Dockerfile delete mode 100644 harnesses/metadata-coverage/alertmanager/alertmanager.yml delete mode 100644 harnesses/metadata-coverage/monitoring/alert_rules.yml delete mode 100644 harnesses/metadata-coverage/monitoring/alertmanager.yml delete mode 100644 harnesses/metadata-coverage/prometheus/alert_rules.yml delete mode 100644 harnesses/metadata-coverage/prometheus/alertmanager.yml diff --git a/harnesses/metadata-coverage/alertmanager/Dockerfile b/harnesses/metadata-coverage/alertmanager/Dockerfile deleted file mode 100644 index 2c74ae71..00000000 --- a/harnesses/metadata-coverage/alertmanager/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM prom/alertmanager:latest - -# Copy AlertManager configuration -COPY alertmanager.yml /etc/alertmanager/alertmanager.yml - -# Expose AlertManager port -EXPOSE 9093 - -# Run AlertManager -CMD ["--config.file=/etc/alertmanager/alertmanager.yml", \ - "--storage.path=/alertmanager", \ - "--log.level=debug"] diff --git a/harnesses/metadata-coverage/alertmanager/alertmanager.yml b/harnesses/metadata-coverage/alertmanager/alertmanager.yml deleted file mode 100644 index c50fd32f..00000000 --- a/harnesses/metadata-coverage/alertmanager/alertmanager.yml +++ /dev/null @@ -1,40 +0,0 @@ -global: - resolve_timeout: 5m - -route: - group_by: ['alertname', 'chain', 'aggregator'] - group_wait: 10s - group_interval: 30s - repeat_interval: 5m - receiver: 'slack-webhook' - -receivers: - # Replace with your own Slack-bridge webhook URL before deploying. - # In your alertmanager container's entrypoint, run - # envsubst < alertmanager.yml.tpl > alertmanager.yml - # so the value comes from the SLACK_WEBHOOK_URL env var, not the repo. - - name: 'slack-webhook' - webhook_configs: - - url: 'https://example.invalid/your-slack-webhook' - send_resolved: true - -inhibit_rules: - # Inhibit warning alerts if critical alert is firing - - source_match: - severity: 'critical' - target_match: - severity: 'warning' - equal: ['alertname', 'chain', 'aggregator'] - - # Inhibit stale metrics alerts if service is down - - source_match: - alert_type: 'service_down' - target_match: - alert_type: 'stale_metrics' - - # Inhibit stale metrics if missing metrics alert is firing - - source_match: - alert_type: 'missing_metrics' - target_match: - alert_type: 'stale_metrics' - equal: ['aggregator'] diff --git a/harnesses/metadata-coverage/docker-compose.yml b/harnesses/metadata-coverage/docker-compose.yml index f011653a..77f7c39b 100644 --- a/harnesses/metadata-coverage/docker-compose.yml +++ b/harnesses/metadata-coverage/docker-compose.yml @@ -21,7 +21,6 @@ services: - "9090:9090" volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml - - ./monitoring/alert_rules.yml:/etc/prometheus/alert_rules.yml - prometheus_data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' @@ -35,20 +34,6 @@ services: depends_on: - monitor - alertmanager: - image: prom/alertmanager:latest - container_name: alertmanager - ports: - - "9093:9093" - volumes: - - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml - command: - - '--config.file=/etc/alertmanager/alertmanager.yml' - - '--storage.path=/alertmanager' - networks: - - monitoring - restart: unless-stopped - grafana: image: grafana/grafana:latest container_name: grafana diff --git a/harnesses/metadata-coverage/monitoring/alert_rules.yml b/harnesses/metadata-coverage/monitoring/alert_rules.yml deleted file mode 100644 index e08b8a73..00000000 --- a/harnesses/metadata-coverage/monitoring/alert_rules.yml +++ /dev/null @@ -1,127 +0,0 @@ -groups: - - name: aggregator_latency_alerts - interval: 30s - rules: - # Missing metrics alerts - - alert: MissingMobulaMetrics - expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) - for: 5m - labels: - severity: critical - aggregator: mobula - alert_type: missing_metrics - annotations: - summary: "Mobula metrics are missing" - description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." - - # Stale metrics alerts (>20 minutes without update) - - alert: MobulaEthereumStaleMetrics - expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"})) > 1200 - for: 2m - labels: - severity: warning - aggregator: mobula - chain: ethereum - alert_type: stale_metrics - annotations: - summary: "Mobula Ethereum metrics are stale" - description: "Mobula Ethereum REST API hasn't responded in over 20 minutes. Check API connectivity." - - - alert: MobulaBaseStaleMetrics - expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 - for: 2m - labels: - severity: warning - aggregator: mobula - chain: base - alert_type: stale_metrics - annotations: - summary: "Mobula Base metrics are stale" - description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." - - # Latency spike alerts - - alert: MobulaEthereumLatencySpike - expr: | - ( - rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="ethereum"}[5m]) / - rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"}[5m]) - ) > 500 - for: 3m - labels: - severity: warning - aggregator: mobula - chain: ethereum - alert_type: latency_spike - annotations: - summary: "Mobula Ethereum latency spike" - description: "Mobula Ethereum average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" - - - alert: MobulaBaseLatencySpike - expr: | - ( - rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / - rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) - ) > 500 - for: 3m - labels: - severity: warning - aggregator: mobula - chain: base - alert_type: latency_spike - annotations: - summary: "Mobula Base latency spike" - description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" - - # Instant spike detection for Mobula (any single request >10s) - - alert: MobulaInstantLatencySpike - expr: | - rest_api_latency_milliseconds{aggregator="mobula"} > 10000 - labels: - severity: warning - aggregator: mobula - alert_type: instant_spike - annotations: - summary: "Mobula instant latency spike on {{ $labels.chain }}" - description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." - - # Extreme latency spikes (>5x normal) - - alert: MobulaExtremeLatencySpike - expr: | - ( - rate(rest_api_latency_milliseconds_sum{aggregator="mobula"}[5m]) / - rate(rest_api_latency_milliseconds_count{aggregator="mobula"}[5m]) - ) > 1000 - for: 1m - labels: - severity: critical - aggregator: mobula - alert_type: extreme_latency - annotations: - summary: "Mobula EXTREME latency spike on {{ $labels.chain }}" - description: "Mobula {{ $labels.chain }} latency is {{ $value | humanize }}ms (>1000ms). Possible service degradation." - - # Error rate alerts - - alert: HighRESTErrorRate - expr: | - ( - rate(rest_api_errors_total[5m]) / - (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) - ) > 0.1 - for: 5m - labels: - severity: warning - alert_type: high_error_rate - annotations: - summary: "High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}" - description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" - - # Service availability - - alert: CodexServiceDown - expr: up{job="latency_monitor"} == 0 - for: 2m - labels: - severity: critical - alert_type: service_down - annotations: - summary: "Latency monitor service is down" - description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/metadata-coverage/monitoring/alertmanager.yml b/harnesses/metadata-coverage/monitoring/alertmanager.yml deleted file mode 100644 index c6a4492f..00000000 --- a/harnesses/metadata-coverage/monitoring/alertmanager.yml +++ /dev/null @@ -1,40 +0,0 @@ -global: - resolve_timeout: 5m - -route: - group_by: ['alertname', 'chain', 'aggregator'] - group_wait: 10s - group_interval: 30s - repeat_interval: 4h - receiver: 'slack-webhook' - -receivers: - # Replace with your own Slack-bridge webhook URL before deploying. - # In your alertmanager container's entrypoint, run - # envsubst < alertmanager.yml.tpl > alertmanager.yml - # so the value comes from the SLACK_WEBHOOK_URL env var, not the repo. - - name: 'slack-webhook' - webhook_configs: - - url: 'https://example.invalid/your-slack-webhook' - send_resolved: true - -inhibit_rules: - # Inhibit warning alerts if critical alert is firing - - source_match: - severity: 'critical' - target_match: - severity: 'warning' - equal: ['alertname', 'chain', 'aggregator'] - - # Inhibit stale metrics alerts if service is down - - source_match: - alert_type: 'service_down' - target_match: - alert_type: 'stale_metrics' - - # Inhibit stale metrics if missing metrics alert is firing - - source_match: - alert_type: 'missing_metrics' - target_match: - alert_type: 'stale_metrics' - equal: ['aggregator'] diff --git a/harnesses/metadata-coverage/monitoring/prometheus.yml b/harnesses/metadata-coverage/monitoring/prometheus.yml index bd7cf041..5650172c 100644 --- a/harnesses/metadata-coverage/monitoring/prometheus.yml +++ b/harnesses/metadata-coverage/monitoring/prometheus.yml @@ -2,16 +2,6 @@ global: scrape_interval: 5s evaluation_interval: 5s -# Load alert rules -rule_files: - - '/etc/prometheus/alert_rules.yml' - -# Alertmanager configuration -alerting: - alertmanagers: - - static_configs: - - targets: ['alertmanager:9093'] - scrape_configs: - job_name: 'latency_monitor' static_configs: diff --git a/harnesses/metadata-coverage/prometheus/Dockerfile b/harnesses/metadata-coverage/prometheus/Dockerfile index a422384f..377f3e68 100644 --- a/harnesses/metadata-coverage/prometheus/Dockerfile +++ b/harnesses/metadata-coverage/prometheus/Dockerfile @@ -6,9 +6,7 @@ USER root ARG PROMETHEUS_CONFIG=prometheus.yml COPY ${PROMETHEUS_CONFIG} /etc/prometheus/prometheus.yml -COPY alert_rules.yml /etc/prometheus/alert_rules.yml EXPOSE 9090 -# --enable-feature=expand-external-labels: lets global.external_labels read ${ENVIRONMENT} from env -CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle", "--web.enable-admin-api", "--enable-feature=expand-external-labels"] +CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle", "--web.enable-admin-api"] diff --git a/harnesses/metadata-coverage/prometheus/alert_rules.yml b/harnesses/metadata-coverage/prometheus/alert_rules.yml deleted file mode 100644 index dd89ab5e..00000000 --- a/harnesses/metadata-coverage/prometheus/alert_rules.yml +++ /dev/null @@ -1,223 +0,0 @@ -groups: - - name: aggregator_latency_alerts - interval: 30s - rules: - # Missing metrics alerts - - alert: MissingMobulaMetrics - expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) - for: 5m - labels: - severity: critical - aggregator: mobula - alert_type: missing_metrics - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula metrics are missing' - description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." - - # Stale metrics alerts (>20 minutes without update) - - alert: MobulaBaseStaleMetrics - expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 - for: 2m - labels: - severity: warning - aggregator: mobula - chain: base - alert_type: stale_metrics - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base metrics are stale' - description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." - - # Latency spike alerts - - alert: MobulaSolanaLatencySpike - expr: | - ( - rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="solana"}[5m]) / - rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="solana"}[5m]) - ) > 2000 - for: 3m - labels: - severity: warning - aggregator: mobula - chain: solana - alert_type: latency_spike - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Solana latency spike' - description: "Mobula Solana average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2000ms)" - - - alert: MobulaBaseLatencySpike - expr: | - ( - rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / - rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) - ) > 2500 - for: 3m - labels: - severity: warning - aggregator: mobula - chain: base - alert_type: latency_spike - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base latency spike' - description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" - - - alert: MobulaBNBLatencySpike - expr: | - ( - rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="bnb"}[5m]) / - rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="bnb"}[5m]) - ) > 2500 - for: 3m - labels: - severity: warning - aggregator: mobula - chain: bnb - alert_type: latency_spike - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula BNB latency spike' - description: "Mobula BNB average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" - - # Instant spike detection for Mobula (any single request >10s) - - alert: MobulaInstantLatencySpike - expr: | - rest_api_latency_milliseconds{aggregator="mobula"} > 10000 - labels: - severity: warning - aggregator: mobula - alert_type: instant_spike - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula instant latency spike on {{ $labels.chain }}' - description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." - - # Error rate alerts - - alert: HighRESTErrorRate - expr: | - ( - rate(rest_api_errors_total[5m]) / - (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) - ) > 0.1 - for: 5m - labels: - severity: warning - alert_type: high_error_rate - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}' - description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" - - # Head lag (WebSocket latency) alerts - per-chain thresholds - # NOTE: alerts use fixed-cardinality gauges now (no tx_hash label) to avoid stale-series spam. - # Sustained 30s window avoids single-spike firings (reconnect bursts, transient network jitter). - - alert: MobulaHeadLagSpikeBase - expr: mobula_head_lag_detailed_seconds{chain="base"} > 2.5 - for: 30s - labels: - severity: warning - aggregator: mobula - alert_type: head_lag_spike - app: aggregator_latency_monitor - chain: base - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Base' - description: | - **Mobula WebSocket Latency Spike - Base** - - **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) - • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} - • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} - - • Pool: {{ $labels.pool_address }} - • Region: {{ $labels.region }} - • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://basescan.org/tx/{{ . | first | label "tx_hash" }}{{ end }} - - - alert: MobulaHeadLagSpikeSolana - expr: mobula_head_lag_detailed_seconds{chain="solana"} > 2 - for: 30s - labels: - severity: warning - aggregator: mobula - alert_type: head_lag_spike - app: aggregator_latency_monitor - chain: solana - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Solana' - description: | - **Mobula WebSocket Latency Spike - Solana** - - **Latency:** {{ $value }}s (threshold: 2s, sustained ≥30s) - • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} - • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} - - • Pool: {{ $labels.pool_address }} - • Region: {{ $labels.region }} - • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://solscan.io/tx/{{ . | first | label "tx_hash" }}{{ end }} - - - alert: MobulaHeadLagSpikeBNB - expr: mobula_head_lag_detailed_seconds{chain="bnb"} > 2.5 - for: 30s - labels: - severity: warning - aggregator: mobula - alert_type: head_lag_spike - app: aggregator_latency_monitor - chain: bnb - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on BNB Chain' - description: | - **Mobula WebSocket Latency Spike - BNB Chain** - - **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) - • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} - • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} - - • Pool: {{ $labels.pool_address }} - • Region: {{ $labels.region }} - • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://bscscan.com/tx/{{ . | first | label "tx_hash" }}{{ end }} - - # Missing head lag metrics (no data for 5 minutes = monitor down) - - alert: MissingHeadLagMetrics - expr: absent(head_lag_seconds) - for: 5m - labels: - severity: critical - alert_type: missing_head_lag - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Head lag metrics missing' - description: "No head_lag_seconds metrics received for 5 minutes. Check if monitors are running." - - # Per-aggregator staleness: fires when one provider stops pushing data - # (e.g. Codex WS disconnect) while others keep running — global absent() - # would NOT catch this. - - alert: AggregatorHeadLagStale - expr: (time() - timestamp(head_lag_seconds)) > 300 - for: 1m - labels: - severity: warning - alert_type: head_lag_stale - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}{{ $labels.aggregator }} head_lag stale on {{ $labels.chain }} ({{ $labels.region }})' - description: | - **{{ $labels.aggregator }}** hasn't pushed a head_lag sample for **{{ $labels.chain }} / {{ $labels.region }}** in over 5 minutes. - - Likely cause: WebSocket disconnected, JWT expired, proxy IP banned, or auth cookie rotated. - - Check the monitor logs for `[HEAD-LAG][{{ $labels.aggregator | toUpper }}]` errors. - - # Service availability - - alert: CodexServiceDown - expr: up{job="latency_monitor"} == 0 - for: 2m - labels: - severity: critical - alert_type: service_down - app: aggregator_latency_monitor - annotations: - summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Latency monitor service is down' - description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/metadata-coverage/prometheus/alertmanager.yml b/harnesses/metadata-coverage/prometheus/alertmanager.yml deleted file mode 100644 index c6a4492f..00000000 --- a/harnesses/metadata-coverage/prometheus/alertmanager.yml +++ /dev/null @@ -1,40 +0,0 @@ -global: - resolve_timeout: 5m - -route: - group_by: ['alertname', 'chain', 'aggregator'] - group_wait: 10s - group_interval: 30s - repeat_interval: 4h - receiver: 'slack-webhook' - -receivers: - # Replace with your own Slack-bridge webhook URL before deploying. - # In your alertmanager container's entrypoint, run - # envsubst < alertmanager.yml.tpl > alertmanager.yml - # so the value comes from the SLACK_WEBHOOK_URL env var, not the repo. - - name: 'slack-webhook' - webhook_configs: - - url: 'https://example.invalid/your-slack-webhook' - send_resolved: true - -inhibit_rules: - # Inhibit warning alerts if critical alert is firing - - source_match: - severity: 'critical' - target_match: - severity: 'warning' - equal: ['alertname', 'chain', 'aggregator'] - - # Inhibit stale metrics alerts if service is down - - source_match: - alert_type: 'service_down' - target_match: - alert_type: 'stale_metrics' - - # Inhibit stale metrics if missing metrics alert is firing - - source_match: - alert_type: 'missing_metrics' - target_match: - alert_type: 'stale_metrics' - equal: ['aggregator'] diff --git a/harnesses/metadata-coverage/prometheus/prometheus.staging.yml b/harnesses/metadata-coverage/prometheus/prometheus.staging.yml index a5c329de..2826fc45 100644 --- a/harnesses/metadata-coverage/prometheus/prometheus.staging.yml +++ b/harnesses/metadata-coverage/prometheus/prometheus.staging.yml @@ -1,19 +1,6 @@ global: scrape_interval: 15s evaluation_interval: 15s - # Staging external label triggers [STAGING] prefix in alert summaries via templating. - external_labels: - environment: 'staging' - -# Load alert rules (shared with production; [STAGING] prefix is conditional via $externalLabels) -rule_files: - - '/etc/prometheus/alert_rules.yml' - -# Staging Alertmanager (separate from production) -alerting: - alertmanagers: - - static_configs: - - targets: ['alertmanager-staging.railway.internal:9093'] # Staging scrape targets — 3 regions, same pattern as production scrape_configs: diff --git a/harnesses/metadata-coverage/prometheus/prometheus.yml b/harnesses/metadata-coverage/prometheus/prometheus.yml index f9a4dfce..5ddc9730 100644 --- a/harnesses/metadata-coverage/prometheus/prometheus.yml +++ b/harnesses/metadata-coverage/prometheus/prometheus.yml @@ -1,20 +1,6 @@ global: scrape_interval: 15s evaluation_interval: 15s - # Expanded at startup via --enable-feature=expand-external-labels. - # Unset => empty value => alert rule templates treat it as "not staging" and don't prefix. - external_labels: - environment: '${ENVIRONMENT}' - -# Load alert rules -rule_files: - - '/etc/prometheus/alert_rules.yml' - -# Alertmanager configuration -alerting: - alertmanagers: - - static_configs: - - targets: ['alertmanager.railway.internal:9093'] scrape_configs: - job_name: 'monitor' From 97991e60dd5cbee1a226478550b5118dcea37004 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 12:17:44 +0200 Subject: [PATCH 026/174] restore alerting with env-var webhook (envsubst pattern) --- .../metadata-coverage/alertmanager/Dockerfile | 15 ++ .../alertmanager/alertmanager.yml | 39 +++ .../alertmanager/entrypoint.sh | 18 ++ .../metadata-coverage/docker-compose.yml | 14 ++ .../monitoring/alert_rules.yml | 127 ++++++++++ .../monitoring/alertmanager.yml | 39 +++ .../monitoring/prometheus.yml | 10 + .../metadata-coverage/prometheus/Dockerfile | 4 +- .../prometheus/alert_rules.yml | 223 ++++++++++++++++++ .../prometheus/alertmanager.yml | 39 +++ .../prometheus/prometheus.staging.yml | 13 + .../prometheus/prometheus.yml | 14 ++ 12 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 harnesses/metadata-coverage/alertmanager/Dockerfile create mode 100644 harnesses/metadata-coverage/alertmanager/alertmanager.yml create mode 100755 harnesses/metadata-coverage/alertmanager/entrypoint.sh create mode 100644 harnesses/metadata-coverage/monitoring/alert_rules.yml create mode 100644 harnesses/metadata-coverage/monitoring/alertmanager.yml create mode 100644 harnesses/metadata-coverage/prometheus/alert_rules.yml create mode 100644 harnesses/metadata-coverage/prometheus/alertmanager.yml diff --git a/harnesses/metadata-coverage/alertmanager/Dockerfile b/harnesses/metadata-coverage/alertmanager/Dockerfile new file mode 100644 index 00000000..c7169802 --- /dev/null +++ b/harnesses/metadata-coverage/alertmanager/Dockerfile @@ -0,0 +1,15 @@ +FROM prom/alertmanager:latest + +# Config carries __SLACK_WEBHOOK_URL__; entrypoint.sh sed-substitutes +# it from $SLACK_WEBHOOK_URL at container start. Keeps the real webhook +# URL out of the repo and out of the image layer. +COPY alertmanager.yml /etc/alertmanager/alertmanager.yml +COPY entrypoint.sh /entrypoint.sh + +USER root +RUN chmod +x /entrypoint.sh +USER nobody + +EXPOSE 9093 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/harnesses/metadata-coverage/alertmanager/alertmanager.yml b/harnesses/metadata-coverage/alertmanager/alertmanager.yml new file mode 100644 index 00000000..a498dc05 --- /dev/null +++ b/harnesses/metadata-coverage/alertmanager/alertmanager.yml @@ -0,0 +1,39 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 5m + receiver: 'slack-webhook' + +receivers: + # __SLACK_WEBHOOK_URL__ is substituted at container startup by entrypoint.sh + # from the SLACK_WEBHOOK_URL env var (see alertmanager/Dockerfile + entrypoint.sh). + # Set SLACK_WEBHOOK_URL in your Railway / .env to your real webhook before deploy. + - name: 'slack-webhook' + webhook_configs: + - url: '__SLACK_WEBHOOK_URL__' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/metadata-coverage/alertmanager/entrypoint.sh b/harnesses/metadata-coverage/alertmanager/entrypoint.sh new file mode 100755 index 00000000..425532d9 --- /dev/null +++ b/harnesses/metadata-coverage/alertmanager/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Substitute __SLACK_WEBHOOK_URL__ in the config from $SLACK_WEBHOOK_URL, +# then exec alertmanager. Writes to /tmp so the source (which may be a +# read-only mounted volume in docker-compose) is never modified. +set -e + +if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then + echo "WARN: SLACK_WEBHOOK_URL not set — slack-webhook receiver will get a placeholder URL and silently fail to deliver." >&2 + SLACK_WEBHOOK_URL="https://example.invalid/no-webhook-set" +fi + +sed "s|__SLACK_WEBHOOK_URL__|${SLACK_WEBHOOK_URL}|g" \ + /etc/alertmanager/alertmanager.yml > /tmp/alertmanager.yml + +exec /bin/alertmanager \ + --config.file=/tmp/alertmanager.yml \ + --storage.path=/alertmanager \ + --log.level=debug diff --git a/harnesses/metadata-coverage/docker-compose.yml b/harnesses/metadata-coverage/docker-compose.yml index 77f7c39b..b47bc3ec 100644 --- a/harnesses/metadata-coverage/docker-compose.yml +++ b/harnesses/metadata-coverage/docker-compose.yml @@ -21,6 +21,7 @@ services: - "9090:9090" volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + - ./monitoring/alert_rules.yml:/etc/prometheus/alert_rules.yml - prometheus_data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' @@ -34,6 +35,19 @@ services: depends_on: - monitor + alertmanager: + build: + context: ./alertmanager + dockerfile: Dockerfile + container_name: alertmanager + ports: + - "9093:9093" + environment: + - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL} + networks: + - monitoring + restart: unless-stopped + grafana: image: grafana/grafana:latest container_name: grafana diff --git a/harnesses/metadata-coverage/monitoring/alert_rules.yml b/harnesses/metadata-coverage/monitoring/alert_rules.yml new file mode 100644 index 00000000..e08b8a73 --- /dev/null +++ b/harnesses/metadata-coverage/monitoring/alert_rules.yml @@ -0,0 +1,127 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # Missing metrics alerts + - alert: MissingMobulaMetrics + expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) + for: 5m + labels: + severity: critical + aggregator: mobula + alert_type: missing_metrics + annotations: + summary: "Mobula metrics are missing" + description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." + + # Stale metrics alerts (>20 minutes without update) + - alert: MobulaEthereumStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: ethereum + alert_type: stale_metrics + annotations: + summary: "Mobula Ethereum metrics are stale" + description: "Mobula Ethereum REST API hasn't responded in over 20 minutes. Check API connectivity." + + - alert: MobulaBaseStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: stale_metrics + annotations: + summary: "Mobula Base metrics are stale" + description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." + + # Latency spike alerts + - alert: MobulaEthereumLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="ethereum"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"}[5m]) + ) > 500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: ethereum + alert_type: latency_spike + annotations: + summary: "Mobula Ethereum latency spike" + description: "Mobula Ethereum average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" + + - alert: MobulaBaseLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) + ) > 500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: latency_spike + annotations: + summary: "Mobula Base latency spike" + description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" + + # Instant spike detection for Mobula (any single request >10s) + - alert: MobulaInstantLatencySpike + expr: | + rest_api_latency_milliseconds{aggregator="mobula"} > 10000 + labels: + severity: warning + aggregator: mobula + alert_type: instant_spike + annotations: + summary: "Mobula instant latency spike on {{ $labels.chain }}" + description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." + + # Extreme latency spikes (>5x normal) + - alert: MobulaExtremeLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula"}[5m]) + ) > 1000 + for: 1m + labels: + severity: critical + aggregator: mobula + alert_type: extreme_latency + annotations: + summary: "Mobula EXTREME latency spike on {{ $labels.chain }}" + description: "Mobula {{ $labels.chain }} latency is {{ $value | humanize }}ms (>1000ms). Possible service degradation." + + # Error rate alerts + - alert: HighRESTErrorRate + expr: | + ( + rate(rest_api_errors_total[5m]) / + (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) + ) > 0.1 + for: 5m + labels: + severity: warning + alert_type: high_error_rate + annotations: + summary: "High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}" + description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + annotations: + summary: "Latency monitor service is down" + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/metadata-coverage/monitoring/alertmanager.yml b/harnesses/metadata-coverage/monitoring/alertmanager.yml new file mode 100644 index 00000000..6c0f533c --- /dev/null +++ b/harnesses/metadata-coverage/monitoring/alertmanager.yml @@ -0,0 +1,39 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + receiver: 'slack-webhook' + +receivers: + # __SLACK_WEBHOOK_URL__ is substituted at container startup by entrypoint.sh + # from the SLACK_WEBHOOK_URL env var (see alertmanager/Dockerfile + entrypoint.sh). + # Set SLACK_WEBHOOK_URL in your Railway / .env to your real webhook before deploy. + - name: 'slack-webhook' + webhook_configs: + - url: '__SLACK_WEBHOOK_URL__' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/metadata-coverage/monitoring/prometheus.yml b/harnesses/metadata-coverage/monitoring/prometheus.yml index 5650172c..bd7cf041 100644 --- a/harnesses/metadata-coverage/monitoring/prometheus.yml +++ b/harnesses/metadata-coverage/monitoring/prometheus.yml @@ -2,6 +2,16 @@ global: scrape_interval: 5s evaluation_interval: 5s +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + scrape_configs: - job_name: 'latency_monitor' static_configs: diff --git a/harnesses/metadata-coverage/prometheus/Dockerfile b/harnesses/metadata-coverage/prometheus/Dockerfile index 377f3e68..a422384f 100644 --- a/harnesses/metadata-coverage/prometheus/Dockerfile +++ b/harnesses/metadata-coverage/prometheus/Dockerfile @@ -6,7 +6,9 @@ USER root ARG PROMETHEUS_CONFIG=prometheus.yml COPY ${PROMETHEUS_CONFIG} /etc/prometheus/prometheus.yml +COPY alert_rules.yml /etc/prometheus/alert_rules.yml EXPOSE 9090 -CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle", "--web.enable-admin-api"] +# --enable-feature=expand-external-labels: lets global.external_labels read ${ENVIRONMENT} from env +CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle", "--web.enable-admin-api", "--enable-feature=expand-external-labels"] diff --git a/harnesses/metadata-coverage/prometheus/alert_rules.yml b/harnesses/metadata-coverage/prometheus/alert_rules.yml new file mode 100644 index 00000000..dd89ab5e --- /dev/null +++ b/harnesses/metadata-coverage/prometheus/alert_rules.yml @@ -0,0 +1,223 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # Missing metrics alerts + - alert: MissingMobulaMetrics + expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) + for: 5m + labels: + severity: critical + aggregator: mobula + alert_type: missing_metrics + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula metrics are missing' + description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." + + # Stale metrics alerts (>20 minutes without update) + - alert: MobulaBaseStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: stale_metrics + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base metrics are stale' + description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." + + # Latency spike alerts + - alert: MobulaSolanaLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="solana"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="solana"}[5m]) + ) > 2000 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: solana + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Solana latency spike' + description: "Mobula Solana average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2000ms)" + + - alert: MobulaBaseLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) + ) > 2500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base latency spike' + description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" + + - alert: MobulaBNBLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="bnb"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="bnb"}[5m]) + ) > 2500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: bnb + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula BNB latency spike' + description: "Mobula BNB average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" + + # Instant spike detection for Mobula (any single request >10s) + - alert: MobulaInstantLatencySpike + expr: | + rest_api_latency_milliseconds{aggregator="mobula"} > 10000 + labels: + severity: warning + aggregator: mobula + alert_type: instant_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula instant latency spike on {{ $labels.chain }}' + description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." + + # Error rate alerts + - alert: HighRESTErrorRate + expr: | + ( + rate(rest_api_errors_total[5m]) / + (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) + ) > 0.1 + for: 5m + labels: + severity: warning + alert_type: high_error_rate + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}' + description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" + + # Head lag (WebSocket latency) alerts - per-chain thresholds + # NOTE: alerts use fixed-cardinality gauges now (no tx_hash label) to avoid stale-series spam. + # Sustained 30s window avoids single-spike firings (reconnect bursts, transient network jitter). + - alert: MobulaHeadLagSpikeBase + expr: mobula_head_lag_detailed_seconds{chain="base"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: base + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Base' + description: | + **Mobula WebSocket Latency Spike - Base** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://basescan.org/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeSolana + expr: mobula_head_lag_detailed_seconds{chain="solana"} > 2 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: solana + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Solana' + description: | + **Mobula WebSocket Latency Spike - Solana** + + **Latency:** {{ $value }}s (threshold: 2s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://solscan.io/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeBNB + expr: mobula_head_lag_detailed_seconds{chain="bnb"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: bnb + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on BNB Chain' + description: | + **Mobula WebSocket Latency Spike - BNB Chain** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://bscscan.com/tx/{{ . | first | label "tx_hash" }}{{ end }} + + # Missing head lag metrics (no data for 5 minutes = monitor down) + - alert: MissingHeadLagMetrics + expr: absent(head_lag_seconds) + for: 5m + labels: + severity: critical + alert_type: missing_head_lag + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Head lag metrics missing' + description: "No head_lag_seconds metrics received for 5 minutes. Check if monitors are running." + + # Per-aggregator staleness: fires when one provider stops pushing data + # (e.g. Codex WS disconnect) while others keep running — global absent() + # would NOT catch this. + - alert: AggregatorHeadLagStale + expr: (time() - timestamp(head_lag_seconds)) > 300 + for: 1m + labels: + severity: warning + alert_type: head_lag_stale + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}{{ $labels.aggregator }} head_lag stale on {{ $labels.chain }} ({{ $labels.region }})' + description: | + **{{ $labels.aggregator }}** hasn't pushed a head_lag sample for **{{ $labels.chain }} / {{ $labels.region }}** in over 5 minutes. + + Likely cause: WebSocket disconnected, JWT expired, proxy IP banned, or auth cookie rotated. + + Check the monitor logs for `[HEAD-LAG][{{ $labels.aggregator | toUpper }}]` errors. + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Latency monitor service is down' + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/metadata-coverage/prometheus/alertmanager.yml b/harnesses/metadata-coverage/prometheus/alertmanager.yml new file mode 100644 index 00000000..6c0f533c --- /dev/null +++ b/harnesses/metadata-coverage/prometheus/alertmanager.yml @@ -0,0 +1,39 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + receiver: 'slack-webhook' + +receivers: + # __SLACK_WEBHOOK_URL__ is substituted at container startup by entrypoint.sh + # from the SLACK_WEBHOOK_URL env var (see alertmanager/Dockerfile + entrypoint.sh). + # Set SLACK_WEBHOOK_URL in your Railway / .env to your real webhook before deploy. + - name: 'slack-webhook' + webhook_configs: + - url: '__SLACK_WEBHOOK_URL__' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/metadata-coverage/prometheus/prometheus.staging.yml b/harnesses/metadata-coverage/prometheus/prometheus.staging.yml index 2826fc45..a5c329de 100644 --- a/harnesses/metadata-coverage/prometheus/prometheus.staging.yml +++ b/harnesses/metadata-coverage/prometheus/prometheus.staging.yml @@ -1,6 +1,19 @@ global: scrape_interval: 15s evaluation_interval: 15s + # Staging external label triggers [STAGING] prefix in alert summaries via templating. + external_labels: + environment: 'staging' + +# Load alert rules (shared with production; [STAGING] prefix is conditional via $externalLabels) +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Staging Alertmanager (separate from production) +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager-staging.railway.internal:9093'] # Staging scrape targets — 3 regions, same pattern as production scrape_configs: diff --git a/harnesses/metadata-coverage/prometheus/prometheus.yml b/harnesses/metadata-coverage/prometheus/prometheus.yml index 5ddc9730..f9a4dfce 100644 --- a/harnesses/metadata-coverage/prometheus/prometheus.yml +++ b/harnesses/metadata-coverage/prometheus/prometheus.yml @@ -1,6 +1,20 @@ global: scrape_interval: 15s evaluation_interval: 15s + # Expanded at startup via --enable-feature=expand-external-labels. + # Unset => empty value => alert rule templates treat it as "not staging" and don't prefix. + external_labels: + environment: '${ENVIRONMENT}' + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager.railway.internal:9093'] scrape_configs: - job_name: 'monitor' From 4ddc6361c402fec88440580acd0fd74337f625fc Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 12:21:30 +0200 Subject: [PATCH 027/174] feat(products): wire GMGN logo + registry entry for /products/gmgn Auto-renders /products/gmgn with brand logo, description and bench appearances now that GMGN appears in bench-001 results. Logo SVG provided by maintainer (12KB, official asset). --- public/logos/gmgn.svg | 187 ++++++++++++++++++++++++++++++++++ src/data/provider-registry.ts | 6 ++ src/lib/logo-manifest.ts | 1 + 3 files changed, 194 insertions(+) create mode 100644 public/logos/gmgn.svg diff --git a/public/logos/gmgn.svg b/public/logos/gmgn.svg new file mode 100644 index 00000000..ea348c24 --- /dev/null +++ b/public/logos/gmgn.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 63dbb8cd..0f9ed892 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -55,6 +55,12 @@ export const PROVIDER_REGISTRY: Record = { "DEX data terminal by CoinGecko covering 200+ chains. REST API for pools, tokens, trades, and OHLCV.", twitter: "@GeckoTerminal", }, + gmgn: { + url: "https://gmgn.ai", + description: + "Solana memecoin trading terminal. WebSocket chain_stat feed for per-pair routing events on tracked Solana pools (slot, price, holder activity) used by GMGN's own trading UI.", + twitter: "@gmgnai", + }, jupiter: { url: "https://jup.ag", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 73fee122..b463f230 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -46,6 +46,7 @@ const RAW: Record = { relay: "/logos/relay.svg", lifi: "/logos/lifi.png", geckoterminal: "/logos/geckoterminal.png", + gmgn: "/logos/gmgn.svg", gains: "/logos/gains.png", blockscout: "/logos/blockscout.svg", gmx: "/logos/gmx.svg", From 49190b3602b3d82ca4c7e342f8a1bc1773732269 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 12:41:09 +0200 Subject: [PATCH 028/174] rename relay-link-revenue bench to bridge-revenue --- .../{relay-link-revenue.yml => bridge-revenue.yml} | 13 ++++++++----- .../{relay-link-revenue.md => bridge-revenue.md} | 4 ++-- next.config.ts | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) rename benchmarks/{relay-link-revenue.yml => bridge-revenue.yml} (94%) rename docs/methodology/{relay-link-revenue.md => bridge-revenue.md} (96%) diff --git a/benchmarks/relay-link-revenue.yml b/benchmarks/bridge-revenue.yml similarity index 94% rename from benchmarks/relay-link-revenue.yml rename to benchmarks/bridge-revenue.yml index 604917bd..fbc5c78e 100644 --- a/benchmarks/relay-link-revenue.yml +++ b/benchmarks/bridge-revenue.yml @@ -1,11 +1,11 @@ # OpenChainBench. Bench № 028 -slug: relay-link-revenue +slug: bridge-revenue number: "028" -title: Relay.link implied protocol revenue -seo_title: "Relay.link revenue tracker 2026: implied margin USD per day, take rate bps, cross-chain swap volume" -seo_description: "Live tracker of Relay.link implied protocol revenue. Sum of (USD paid - USD received - gas - disclosed app fees) over all swaps in 24h / 7d / 30d windows. Upper bound on what Relay + solvers collectively keep. Methodology pre-registered, harness open source." -subtitle: Implied margin in USD that Relay.link plus its solver network collectively retain per swap, summed over rolling 24h / 7d / 30d windows. Upper bound, not exact. Read methodology before citing. +title: Cross-chain bridge implied protocol revenue +seo_title: "Bridge revenue tracker 2026: cross-chain protocol implied margin USD per day, take rate bps, swap volume (Relay + comparable bridges)" +seo_description: "Live tracker of cross-chain bridge implied protocol revenue. Sum of (USD paid - USD received - gas - disclosed app fees) over all swaps in 24h / 7d / 30d windows. Upper bound on what each bridge + its solver network collectively keep. Methodology pre-registered, harness open source. Relay tracked today; Across / Socket / deBridge to follow." +subtitle: Implied margin in USD that each cross-chain bridge plus its solver network collectively retain per swap, summed over rolling 24h / 7d / 30d windows. Upper bound, not exact. Read methodology before citing. category: Bridges status: live metric: Implied revenue USD (24h) @@ -118,6 +118,9 @@ faq: a: "Relay.link is a cross-chain swap routing protocol, the closest fit in the OpenChainBench category list is Bridges. Other benches in this category measure bridge fees, bridge quote latency and bridge-monitor finality. A Trading or Aggregators classification would also be defensible since Relay is structurally closer to an intent-settlement layer than a classic bridge, but Bridges is the most discoverable bucket for the readers most likely to want this number." source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/relay-link-revenue +# Harness directory keeps its provider-specific name. Additional bridges +# land as sibling harnesses (e.g. across-revenue, socket-revenue) and feed +# their own provider entries into this same bench spec. prometheus: window: 24h diff --git a/docs/methodology/relay-link-revenue.md b/docs/methodology/bridge-revenue.md similarity index 96% rename from docs/methodology/relay-link-revenue.md rename to docs/methodology/bridge-revenue.md index e7f0834f..f563d8f1 100644 --- a/docs/methodology/relay-link-revenue.md +++ b/docs/methodology/bridge-revenue.md @@ -1,8 +1,8 @@ -# Methodology - Relay.link Implied Protocol Revenue +# Methodology - Cross-chain bridge implied protocol revenue (Relay sub-methodology) > **Pre-registered methodology.** Pinned before any external party is invited to use the bench number for due diligence, deal terms, or commentary. Changes ship as public PRs with a 14-day comment window. Disputes go through public GitHub issues. > -> **Version :** v1.0 - first commit 2026-05-23. Bench № 028 (Relay.link implied protocol revenue). +> **Version :** v1.0 - first commit 2026-05-23. Bench № 028 (cross-chain bridge implied protocol revenue). This file documents the Relay.link sub-methodology; additional bridges (Across, Socket, deBridge) land as sibling sections once their harnesses ship. --- diff --git a/next.config.ts b/next.config.ts index 073ae8e5..bded5430 100644 --- a/next.config.ts +++ b/next.config.ts @@ -74,6 +74,7 @@ const nextConfig: NextConfig = { { source: "/networks", destination: "/", permanent: true }, { source: "/providers", destination: "/products", permanent: true }, { source: "/providers/:slug", destination: "/products/:slug", permanent: true }, + { source: "/benchmarks/relay-link-revenue", destination: "/benchmarks/bridge-revenue", permanent: true }, ]; }, }; From a13326569b7447b6c7e3085db63c6e9da23d8c19 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 29 May 2026 12:44:26 +0200 Subject: [PATCH 029/174] add 30d range to time-series chart --- src/components/time-series-chart.tsx | 22 ++++++++++++++++++---- src/lib/spec.ts | 17 +++++++++++++++-- src/types/benchmark.ts | 4 ++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 402f2a57..a96c0bd5 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -18,14 +18,15 @@ type Props = { headerActions?: import("react").ReactNode; }; -type Range = "1h" | "6h" | "24h" | "7d"; -const RANGES: Range[] = ["1h", "6h", "24h", "7d"]; +type Range = "1h" | "6h" | "24h" | "7d" | "30d"; +const RANGES: Range[] = ["1h", "6h", "24h", "7d", "30d"]; const RANGE_HOURS: Record = { "1h": 1, "6h": 6, "24h": 24, "7d": 168, + "30d": 720, }; const RANGE_LABEL: Record = { @@ -33,6 +34,7 @@ const RANGE_LABEL: Record = { "6h": "last 6 hours", "24h": "last 24 hours", "7d": "last 7 days", + "30d": "last 30 days", }; const REGION_LABEL: Record = { @@ -52,6 +54,10 @@ export function TimeSeriesChart({ benchmark, region: regionProp, headerActions } !!benchmark.extras.series7d && Object.keys(benchmark.extras.series7d).length > 0; + const has30d = + !!benchmark.extras.series30d && + Object.keys(benchmark.extras.series30d).length > 0; + const availableRegions = useMemo(() => { const set = new Set(); const byRegion = benchmark.extras.seriesByRegion24h ?? {}; @@ -103,7 +109,8 @@ export function TimeSeriesChart({ benchmark, region: regionProp, headerActions } {/* CSS animations */} + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 63dbb8cd..9645fb01 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -61,6 +61,18 @@ export const PROVIDER_REGISTRY: Record = { "Solana swap aggregator. REST APIs for quotes, swap routing, price, and token lists across Solana DEXs.", twitter: "@JupiterExchange", }, + raydium: { + url: "https://raydium.io", + description: + "Solana AMM with both standard constant-product pools and concentrated liquidity (CLMM). Trade API exposes single-venue swap quotes and route construction over Raydium-owned liquidity only — not an aggregator.", + twitter: "@RaydiumProtocol", + }, + openocean: { + url: "https://openocean.finance", + description: + "Multi-chain DEX aggregator. v4 swap API quotes and routes across EVM chains and Solana, aggregating across multiple AMMs with gas-aware path selection.", + twitter: "@OpenOceanGlobal", + }, moralis: { url: "https://moralis.io", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 73fee122..731172f9 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -55,6 +55,8 @@ const RAW: Record = { moralis: "/logos/moralis.png", stellarexpert: "/logos/stellarexpert.png", jupiter: "/logos/jupiter.png", + raydium: "/logos/raydium.svg", + openocean: "/logos/openocean.png", lighter: "/logos/lighter.svg", debridge: "/logos/debridge.svg", From fadfff1c93dbbeb00c51b9950eb0aefd374ee847 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 31 May 2026 14:10:21 +0300 Subject: [PATCH 081/174] bench-029: add region dimension + correct hosting claims (#209) * feat(products): wire raydium + openocean logos and registry entries Logos added by user at repo root, moved to public/logos/ and wired into logo-manifest. Provider registry entries placed next to jupiter (Solana DEX cluster) for bench-029 (solana-dex-quote-latency). * bench-029: add region dimension + correct unverified hosting claims - Add dimensions.region block so the page renders region tabs (us-east, eu-west, sgp) at the top with full-page re-fetch on click, mirroring the rpc-capabilities pattern. - Fix unverified 'Mobula on Railway us-east' claim: DNS shows all 4 providers are CDN-fronted (Cloudflare for 3, CloudFront for Jupiter), so the latency gap is edge-caching policy, not hosting region. Update the methodology disclosure, the per-provider finding, and the FAQ entry to reflect what the data actually shows. --- benchmarks/solana-dex-quote-latency.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/benchmarks/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml index 5cb18a0f..1ff672fa 100644 --- a/benchmarks/solana-dex-quote-latency.yml +++ b/benchmarks/solana-dex-quote-latency.yml @@ -45,12 +45,12 @@ methodology: - "Authentication errors (HTTP 401/403) and rate limits (HTTP 429) are excluded from the latency histogram. They are counted separately and surface on the success rate column." - "Direct egress from Railway, no residential proxy. The bench measures backend-to-backend latency as a real integration would see it." - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000 ms." - - "Disclosure: Mobula operates OpenChainBench. Mobula's API is hosted on Railway us-east, which gives it a network-distance advantage on the us-east region series. The per-region tabs surface this transparently; the cross-region median in the headline neutralises it." + - "Disclosure: Mobula operates OpenChainBench. All four measured endpoints sit behind a CDN (Cloudflare for Mobula, OpenOcean, Raydium; CloudFront for Jupiter), so the visible latency is the sum of edge TLS handshake plus whatever portion of the request the origin actually computes — providers that cache the canonical 1 SOL → USDC response at the edge look faster than providers that re-route every request. The per-region tabs surface this transparently; the cross-region median in the headline neutralises it." findings: - "{{best_name}} leads at {{best_p50}} (p50, 24 h) across {{count}} measured Solana DEX quote APIs. The headline is the cross-region median; the per-region tabs below show where each provider's infrastructure actually sits." - "{{name:jupiter}} returns quotes in {{p50:jupiter}} (p50, 24 h). Jupiter's lite-api endpoint is Cloudflare-fronted and largely region-agnostic — sub-100 ms from any of the three measurement regions." - - "{{name:mobula}} returns quotes in {{p50:mobula}} (p50, 24 h). Mobula's API is co-located on Railway us-east, so the us-east region tab shows the lowest measurable RTT; the eu-west and sgp tabs surface the transatlantic / transpacific cost." + - "{{name:mobula}} returns quotes in {{p50:mobula}} (p50, 24 h). Mobula's quote API runs a live multi-DEX routing search per request rather than serving cached responses, so the per-region tabs reflect the cost of the edge-to-origin hop on top of the routing compute." - "{{name:openocean}} returns quotes in {{p50:openocean}} (p50, 24 h). OpenOcean's v4 Solana endpoint routes a different multi-DEX path engine than Jupiter; the routing search dominates the latency curve." - "{{name:raydium}} returns quotes in {{p50:raydium}} (p50, 24 h). Raydium's Trade API is a single-venue compute endpoint (Raydium pools only) — included as a reference point for what a direct AMM quote costs versus an aggregator." - "p99 is the integration-grade number. {{p99:jupiter}} / {{p99:mobula}} / {{p99:openocean}} / {{p99:raydium}} is what a swap UI feels when a region spikes or a provider's route engine churns." @@ -60,6 +60,18 @@ source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana- prometheus: window: 24h +# Region selector — tabs at the top of the page. Server-side injects +# `region="X"` into every PromQL query for the active tab. The special +# value `all` skips the filter and aggregates across every region. +# Values match the raw harness labels emitted by the MONITOR_REGION env +# var on each Railway service. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: AP-Southeast } + faq: - q: "Which Solana DEX has the fastest quote API right now?" a: "{{best_name}} currently returns quotes the fastest at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 60 seconds against fresh samples, so the answer reflects the last 24 hours of measured latency from three regions (us-east, eu-west, sgp), not a marketing-page claim." @@ -70,7 +82,7 @@ faq: - q: "Why is Jupiter so much faster than Raydium's Trade API?" a: "Jupiter is an aggregator: it computes routes server-side and serves the result from a Cloudflare-fronted edge that's largely region-agnostic. Raydium's Trade API is a single-venue compute endpoint that runs the routing calculation per request against Raydium's own pools only. Raydium isn't optimised for the speed an aggregator front-end needs because it isn't the front-end." - q: "Why is Mobula not the fastest in every region?" - a: "Mobula's quote API is hosted on Railway us-east. The us-east region tab measures Mobula at the lowest possible RTT for that infra. The eu-west and sgp tabs measure with the network distance penalty added — that's an honest reflection of what a user in those regions experiences when they hit Mobula. The per-region tabs surface this; the cross-region median in the headline neutralises it." + a: "Mobula's quote API sits behind Cloudflare and runs a live multi-DEX routing search per request (no cached response bodies for popular pairs). Jupiter, by contrast, fronts the canonical `1 SOL → USDC` quote with aggressive edge caching, so the closest CloudFront PoP returns a pre-computed body without round-tripping to the origin. That's the gap on the most-popular pair; on a less-popular long-tail pair the gap collapses because the CDN cache miss forces every provider back to the origin. The per-region tabs let you see who's CDN-leaning vs origin-leaning — large region-to-region variance indicates edge caching; flat numbers across regions indicate origin compute." - q: "Are authentication errors and rate limits counted as 'slow'?" a: "No. HTTP 401/403/429 responses are excluded from the latency histogram entirely. They are counted in separate counters (`solana_quote_auth_error_total`, `solana_quote_throttled_total`) which show up on the success rate column. A provider that's fast but rate-limits us at the configured cadence loses points on success rate, not on latency." From e7653c93604e1b7673fb79c04ba2bdb9956ea6b0 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 31 May 2026 14:21:01 +0300 Subject: [PATCH 082/174] bench-029: warm-path harness + remove false 'Mobula operates' disclosure (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(products): wire raydium + openocean logos and registry entries Logos added by user at repo root, moved to public/logos/ and wired into logo-manifest. Provider registry entries placed next to jupiter (Solana DEX cluster) for bench-029 (solana-dex-quote-latency). * bench-029: add region dimension + correct unverified hosting claims - Add dimensions.region block so the page renders region tabs (us-east, eu-west, sgp) at the top with full-page re-fetch on click, mirroring the rpc-capabilities pattern. - Fix unverified 'Mobula on Railway us-east' claim: DNS shows all 4 providers are CDN-fronted (Cloudflare for 3, CloudFront for Jupiter), so the latency gap is edge-caching policy, not hosting region. Update the methodology disclosure, the per-provider finding, and the FAQ entry to reflect what the data actually shows. * bench-029: warm-path harness + remove false 'Mobula operates' claim Two coupled changes: 1. Harness switches from cold-path (KeepAlive: -1, DisableKeepAlives: true, new http.Client per tick) to warm-path: one persistent http.Client per provider, TCP+TLS reused across the 60s tick, HTTP/2 attempted. The measured number is now steady-state RTT — what a long-running backend integration actually sees — not the first-call cold handshake penalty. Shared helper in cmd/monitor/http_client.go. 2. Methodology + FAQ updated to reflect warm-path. Drop the inaccurate 'Mobula operates OpenChainBench' disclosure (Mobula sponsors harnesses that it hosts on its Railway infra, but does NOT operate or own the project). Keep the CDN disclosure since DNS verification confirmed all 4 providers sit behind a CDN — that fact still affects how the numbers should be read. --- benchmarks/solana-dex-quote-latency.yml | 6 ++-- harnesses/solana-quote-latency/.gitignore | 1 + harnesses/solana-quote-latency/README.md | 4 +-- .../cmd/monitor/http_client.go | 35 +++++++++++++++++++ .../cmd/monitor/jupiter.go | 22 ++---------- .../cmd/monitor/mobula.go | 21 ++--------- .../cmd/monitor/openocean.go | 21 ++--------- .../cmd/monitor/raydium.go | 21 ++--------- 8 files changed, 53 insertions(+), 78 deletions(-) create mode 100644 harnesses/solana-quote-latency/.gitignore create mode 100644 harnesses/solana-quote-latency/cmd/monitor/http_client.go diff --git a/benchmarks/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml index 1ff672fa..36de1395 100644 --- a/benchmarks/solana-dex-quote-latency.yml +++ b/benchmarks/solana-dex-quote-latency.yml @@ -41,11 +41,11 @@ methodology: - "Canonical quote: 1 SOL → USDC, 50 bps slippage, dummy wallet HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH, no fee account, no referrer, no onlyDirectRoutes." - "Cadence: one quote per provider per region every 60 seconds. 1,440 samples per provider per region per day." - "Regions: us-east, eu-west, sgp." - - "Latency definition: wallclock from HTTP request dispatch to the first byte of a response body that includes the quoted output amount (Jupiter `outAmount`, Mobula `data.outAmount`, OpenOcean `data.outAmount`, Raydium `data.outputAmount`). TCP keepalive disabled so each tick incurs a fresh DNS + TLS handshake — this matches what a cold backend integration measures." + - "Latency definition: wallclock from HTTP request dispatch to the first byte of a response body that includes the quoted output amount (Jupiter `outAmount`, Mobula `data.outAmount`, OpenOcean `data.outAmount`, Raydium `data.outputAmount`). The HTTP client reuses TCP + TLS connections across ticks (warm-path), matching how a long-running backend integration actually consumes the API — the bench measures steady-state round-trip cost, not the first-request cold-start penalty." - "Authentication errors (HTTP 401/403) and rate limits (HTTP 429) are excluded from the latency histogram. They are counted separately and surface on the success rate column." - "Direct egress from Railway, no residential proxy. The bench measures backend-to-backend latency as a real integration would see it." - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000 ms." - - "Disclosure: Mobula operates OpenChainBench. All four measured endpoints sit behind a CDN (Cloudflare for Mobula, OpenOcean, Raydium; CloudFront for Jupiter), so the visible latency is the sum of edge TLS handshake plus whatever portion of the request the origin actually computes — providers that cache the canonical 1 SOL → USDC response at the edge look faster than providers that re-route every request. The per-region tabs surface this transparently; the cross-region median in the headline neutralises it." + - "All four measured endpoints sit behind a CDN (Cloudflare for Mobula, OpenOcean, Raydium; CloudFront for Jupiter), so the visible latency includes whatever portion of the request the origin actually computes — providers that cache the canonical 1 SOL → USDC response at the edge look faster than providers that re-route every request against fresh chain state." findings: - "{{best_name}} leads at {{best_p50}} (p50, 24 h) across {{count}} measured Solana DEX quote APIs. The headline is the cross-region median; the per-region tabs below show where each provider's infrastructure actually sits." @@ -78,7 +78,7 @@ faq: - q: "What is quote latency on a DEX aggregator?" a: "Quote latency is the wallclock time between a swap UI asking a DEX aggregator 'what would I get for 1 SOL right now?' and the aggregator answering with a usable routed price. A quote API at 80 ms feels instant to the user; a quote API at 600 ms introduces a visible delay between input change and updated output. The number sets the floor on how live a swap UI can feel before any other latency (RPC, signing, broadcast) is added." - q: "How is quote latency measured on OpenChainBench?" - a: "A harness in each of three Railway regions (us-east, eu-west, sgp) sends the same canonical quote — 1 SOL → USDC, 50 bps slippage, no fee — to each provider's quote endpoint every 60 seconds. Each request opens a fresh TCP connection (no keepalive reuse) so DNS, TLS and request handling are all counted, the way a cold backend integration measures. The wallclock from request dispatch to the first byte containing the quoted output amount is the recorded latency. p50, p90 and p99 are aggregated via Prometheus `histogram_quantile` over 24 hours." + a: "A harness in each of three Railway regions (us-east, eu-west, sgp) sends the same canonical quote — 1 SOL → USDC, 50 bps slippage, no fee — to each provider's quote endpoint every 60 seconds. The HTTP client reuses TCP and TLS connections across ticks, so the recorded number is the steady-state round-trip a long-lived backend integration sees, not the one-off cold-start handshake. The wallclock from request dispatch to the first byte containing the quoted output amount is the recorded latency. p50, p90 and p99 are aggregated via Prometheus `histogram_quantile` over 24 hours." - q: "Why is Jupiter so much faster than Raydium's Trade API?" a: "Jupiter is an aggregator: it computes routes server-side and serves the result from a Cloudflare-fronted edge that's largely region-agnostic. Raydium's Trade API is a single-venue compute endpoint that runs the routing calculation per request against Raydium's own pools only. Raydium isn't optimised for the speed an aggregator front-end needs because it isn't the front-end." - q: "Why is Mobula not the fastest in every region?" diff --git a/harnesses/solana-quote-latency/.gitignore b/harnesses/solana-quote-latency/.gitignore new file mode 100644 index 00000000..089d940c --- /dev/null +++ b/harnesses/solana-quote-latency/.gitignore @@ -0,0 +1 @@ +/monitor diff --git a/harnesses/solana-quote-latency/README.md b/harnesses/solana-quote-latency/README.md index 097686e6..6c2f8a7c 100644 --- a/harnesses/solana-quote-latency/README.md +++ b/harnesses/solana-quote-latency/README.md @@ -11,7 +11,7 @@ Exposes Prometheus metrics on `:2112/metrics` (OCB Railway convention). One tick = one quote per provider in parallel. Each provider adapter: 1. Builds the canonical request (1 SOL → USDC, 50 bps, dummy wallet `HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH`, no fee, no referrer). -2. Opens a fresh TCP+TLS connection per tick (`net.Dialer{KeepAlive: -1}`). DNS + handshake cost is part of the user-perceived latency we want to measure. +2. Reuses a per-provider `http.Client` configured for keepalive (30s `KeepAlive`, 90s `IdleConnTimeout`, HTTP/2 attempt). After the first tick the TCP + TLS connection is reused, so the wallclock around `client.Do(req)` measures steady-state RTT (request handling + origin work + response), not the cold handshake. 3. Measures wallclock from `http.Client.Do` dispatch to the first byte of a response body containing a usable `outAmount` (`data.outAmount` for Mobula/OpenOcean, `outAmount` for Jupiter, `data.outputAmount` for Raydium). 4. Records the sample in `solana_quote_latency_ms{provider, region}`. 401/403/429/parse errors are excluded from the histogram and tracked separately. @@ -67,7 +67,7 @@ OpenOcean and Raydium have no auth on their public quote endpoints. ## Design notes -- **No keepalive.** Each tick re-opens TCP + TLS. We measure what a cold integration sees, not what a warmed-up internal client sees. +- **Warm-path measurement.** Each provider gets one `http.Client` for the life of the process and reuses TCP + TLS across ticks. This matches how a long-running backend integration actually consumes the API — the measured number is the steady-state RTT, not the first-call cold-start. - **10s per-request timeout.** Slow responses are recorded as `other_error{error_type="timeout"}`, not as 10s latency samples. - **60s tick.** ≈1,440 samples per provider per region per day. - **Histogram-only buckets.** No quantile is computed inside the harness; the bench YAML uses `histogram_quantile()` on Prometheus's side over a 24-hour window. This keeps the percentiles correct across restarts and across replicas. diff --git a/harnesses/solana-quote-latency/cmd/monitor/http_client.go b/harnesses/solana-quote-latency/cmd/monitor/http_client.go new file mode 100644 index 00000000..d3f3349f --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/http_client.go @@ -0,0 +1,35 @@ +package main + +import ( + "net" + "net/http" + "time" +) + +// newWarmHTTPClient returns an http.Client wired for connection reuse. +// +// Bench-029 measures the steady-state RTT a long-running backend integration +// sees, not the first-request cold-start penalty. So every provider adapter +// constructs ONE shared client at boot, reuses TCP + TLS across ticks via +// keepalive, and the only thing the wallclock around `client.Do(req)` +// captures is request handling + origin work + response. +// +// Connection pool sizing assumes the harness probes one endpoint per +// provider on a 60s tick — a couple of idle connections per host is plenty. +func newWarmHTTPClient() *http.Client { + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 5, + MaxIdleConnsPerHost: 2, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: true, + }, + } +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/jupiter.go b/harnesses/solana-quote-latency/cmd/monitor/jupiter.go index dd8b9a90..44cf16e9 100644 --- a/harnesses/solana-quote-latency/cmd/monitor/jupiter.go +++ b/harnesses/solana-quote-latency/cmd/monitor/jupiter.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "net/url" "time" @@ -17,10 +16,11 @@ import ( type JupiterProvider struct { region string apiKey string // optional; lite endpoint works without it + client *http.Client } func NewJupiterProvider(region, apiKey string) *JupiterProvider { - return &JupiterProvider{region: region, apiKey: apiKey} + return &JupiterProvider{region: region, apiKey: apiKey, client: newWarmHTTPClient()} } func (p *JupiterProvider) Slug() string { return "jupiter" } @@ -38,22 +38,6 @@ type jupiterQuoteResp struct { } func (p *JupiterProvider) Probe(ctx context.Context) (int64, bool, error) { - // Fresh TCP/TLS per tick. We want DNS+dial+TLS overhead in the measurement. - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: -1, - }).DialContext, - DisableKeepAlives: true, - MaxIdleConns: 0, - IdleConnTimeout: 0, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } - q := url.Values{} q.Set("inputMint", solMint) q.Set("outputMint", usdcMint) @@ -72,7 +56,7 @@ func (p *JupiterProvider) Probe(ctx context.Context) (int64, bool, error) { } start := time.Now() - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { errType := "network" if errors.Is(err, context.DeadlineExceeded) { diff --git a/harnesses/solana-quote-latency/cmd/monitor/mobula.go b/harnesses/solana-quote-latency/cmd/monitor/mobula.go index b15395f6..12debe35 100644 --- a/harnesses/solana-quote-latency/cmd/monitor/mobula.go +++ b/harnesses/solana-quote-latency/cmd/monitor/mobula.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "net/url" "time" @@ -22,10 +21,11 @@ import ( type MobulaProvider struct { region string apiKey string + client *http.Client } func NewMobulaProvider(region, apiKey string) *MobulaProvider { - return &MobulaProvider{region: region, apiKey: apiKey} + return &MobulaProvider{region: region, apiKey: apiKey, client: newWarmHTTPClient()} } func (p *MobulaProvider) Slug() string { return "mobula" } @@ -48,21 +48,6 @@ func (p *MobulaProvider) Probe(ctx context.Context) (int64, bool, error) { return 0, false, fmt.Errorf("mobula api key missing") } - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: -1, - }).DialContext, - DisableKeepAlives: true, - MaxIdleConns: 0, - IdleConnTimeout: 0, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } - q := url.Values{} q.Set("chainId", "solana") q.Set("tokenIn", solMint) @@ -83,7 +68,7 @@ func (p *MobulaProvider) Probe(ctx context.Context) (int64, bool, error) { req.Header.Set("Authorization", p.apiKey) start := time.Now() - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { errType := "network" if errors.Is(err, context.DeadlineExceeded) { diff --git a/harnesses/solana-quote-latency/cmd/monitor/openocean.go b/harnesses/solana-quote-latency/cmd/monitor/openocean.go index dc63d005..e274b657 100644 --- a/harnesses/solana-quote-latency/cmd/monitor/openocean.go +++ b/harnesses/solana-quote-latency/cmd/monitor/openocean.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "net/url" "time" @@ -18,10 +17,11 @@ import ( // - The body is double-enveloped: {code, data:{code, ..., outAmount}}. type OpenOceanProvider struct { region string + client *http.Client } func NewOpenOceanProvider(region string) *OpenOceanProvider { - return &OpenOceanProvider{region: region} + return &OpenOceanProvider{region: region, client: newWarmHTTPClient()} } func (p *OpenOceanProvider) Slug() string { return "openocean" } @@ -37,21 +37,6 @@ type openOceanQuoteResp struct { } func (p *OpenOceanProvider) Probe(ctx context.Context) (int64, bool, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: -1, - }).DialContext, - DisableKeepAlives: true, - MaxIdleConns: 0, - IdleConnTimeout: 0, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } - q := url.Values{} q.Set("inTokenAddress", solMint) q.Set("outTokenAddress", usdcMint) @@ -70,7 +55,7 @@ func (p *OpenOceanProvider) Probe(ctx context.Context) (int64, bool, error) { req.Header.Set("Accept", "application/json") start := time.Now() - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { errType := "network" if errors.Is(err, context.DeadlineExceeded) { diff --git a/harnesses/solana-quote-latency/cmd/monitor/raydium.go b/harnesses/solana-quote-latency/cmd/monitor/raydium.go index f8ae78ac..c826fda0 100644 --- a/harnesses/solana-quote-latency/cmd/monitor/raydium.go +++ b/harnesses/solana-quote-latency/cmd/monitor/raydium.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "net" "net/http" "net/url" "time" @@ -23,10 +22,11 @@ import ( // - Must verify success == true before reading data. type RaydiumProvider struct { region string + client *http.Client } func NewRaydiumProvider(region string) *RaydiumProvider { - return &RaydiumProvider{region: region} + return &RaydiumProvider{region: region, client: newWarmHTTPClient()} } func (p *RaydiumProvider) Slug() string { return "raydium" } @@ -41,21 +41,6 @@ type raydiumQuoteResp struct { } func (p *RaydiumProvider) Probe(ctx context.Context) (int64, bool, error) { - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: -1, - }).DialContext, - DisableKeepAlives: true, - MaxIdleConns: 0, - IdleConnTimeout: 0, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } - q := url.Values{} q.Set("inputMint", solMint) q.Set("outputMint", usdcMint) @@ -72,7 +57,7 @@ func (p *RaydiumProvider) Probe(ctx context.Context) (int64, bool, error) { req.Header.Set("Accept", "application/json") start := time.Now() - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { errType := "network" if errors.Is(err, context.DeadlineExceeded) { From 4c93b55e0ae2c030884c6fd3979f02a3e772e5ae Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 31 May 2026 13:28:35 +0200 Subject: [PATCH 083/174] alternatives: pad JSON-LD description to Schema.org Dataset 50-char minimum --- src/app/alternatives/[slug]/page.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index 14ae5e17..fa3653d4 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -76,6 +76,24 @@ export default async function AlternativePage({ const url = `${SITE.url}/alternatives/${alt.slug}`; const benchUrl = `${SITE.url}/benchmarks/${bench.slug}`; + + // Schema.org / Google Rich Results requires Dataset.description to be + // between 50 and 5000 chars (Search Console flags "Invalid string + // length" below 50). Most short alt.description fields ("Relay + // alternatives", ~40 chars) trip this. Pad with bench context until + // the threshold is met, then truncate at the 5000 ceiling. + const altDescriptionBase = alt.description ?? alt.intro.slice(0, 280); + const altDescriptionPadded = (() => { + if (altDescriptionBase.length >= 50) return altDescriptionBase.slice(0, 5000); + const altCount = topAlternatives.length; + const peers = altCount > 0 + ? `${altCount} live alternative${altCount === 1 ? "" : "s"}` + : "live alternatives"; + const pad = ` — open benchmark snapshot of ${alt.target_product} versus ${peers} on ${bench.metric}, measured continuously and published with full methodology.`; + const joined = `${altDescriptionBase}${pad}`; + return joined.slice(0, 5000); + })(); + const jsonLd = { "@context": "https://schema.org", "@graph": [ @@ -83,7 +101,7 @@ export default async function AlternativePage({ "@type": "Dataset", "@id": `${url}#dataset`, name: `${alt.target_product} alternatives, benchmark snapshot`, - description: alt.description ?? alt.intro.slice(0, 280), + description: altDescriptionPadded, url, identifier: alt.slug, keywords: [ @@ -104,7 +122,7 @@ export default async function AlternativePage({ "@type": "Article", "@id": `${url}#article`, headline: `${alt.target_product} alternatives`, - description: alt.description ?? alt.intro.slice(0, 280), + description: altDescriptionPadded, url, mainEntityOfPage: url, articleBody: alt.intro, From 8ace1157510a3390235551e9d2e4b436355ab9ce Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 31 May 2026 14:37:26 +0300 Subject: [PATCH 084/174] fix: bust bench cache keys to surface bench-029 (#212) The all-benchmarks-v6 and bench-unfiltered-v4 entries cached before bench-029 (solana-dex-quote-latency) was merged were still serving from Vercel Data Cache, so the new bench was 404 on direct URL and absent from /benchmarks search. Bumping to v5 / v7 forces a fresh build. --- src/lib/spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 7d21ea4e..a324305c 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -93,7 +93,11 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // v4: added providersPerChain so per-chain rank chips only render for // providers that actually returned data on that chain (Solana-only // providers no longer get phantom chips on Base/BNB). - ["bench-unfiltered-v4"], + // v5: forced bust after adding bench-029 (solana-dex-quote-latency) — + // older cache entries for the all-benchmarks list didn't include the + // new slug, so the bench was 404 on direct hit and absent from search + // until the cache aged out. + ["bench-unfiltered-v5"], { revalidate: 60, tags: ["benchmarks"] }, ); @@ -154,7 +158,7 @@ const loadAllBenchmarksCached = unstable_cache( // benchmark slice the products page reads. Without bumping this, the // outer cache can keep serving v5-era benchmarks (no providersPerChain) // even after the inner cache is fresh. - ["all-benchmarks-v6"], + ["all-benchmarks-v7"], { revalidate: 60, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); From 795e719a982cfd51cc6f157c58997d945a246921 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 31 May 2026 14:47:32 +0300 Subject: [PATCH 085/174] fix(brand): distinct provider colors + sgp region glyph (#213) - Jupiter switched from #FF7A00 (amber-orange) to #C7F284 (jupiter matrix green, their secondary brand) so it doesn't collide with mobula's vivid orange on the solana-dex-quote-latency by-region chart. - Add raydium (#00C2FF cyan) and openocean (#2563EB deep blue) so they stop falling through to the editorial palette and reading as similar orange/violet on the chart. - Add sgp brand color + isRegion entry alias to ap-southeast. The bench dimension value is the raw Railway label sgp, but the brand lookup was only registered under ap-southeast, so the SGP region tab had no globe glyph and no colored ring (rpc-capabilities had the same issue). - Rename the SGP region label from 'AP-Southeast' to 'Singapore' on both bench-029 and rpc-capabilities. AP-Southeast is AWS naming; for end users Singapore is clearer. --- benchmarks/rpc-capabilities.yml | 2 +- benchmarks/solana-dex-quote-latency.yml | 2 +- src/lib/brand.ts | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index c0ff9a2b..5a305519 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -122,7 +122,7 @@ dimensions: - { value: all, label: All regions } - { value: us-east, label: US-East } - { value: eu-west, label: EU-West } - - { value: sgp, label: AP-Southeast } + - { value: sgp, label: Singapore } # Real metrics emitted by the rpc-capabilities harness: # rpc_latency_milliseconds{provider, chain} gauge diff --git a/benchmarks/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml index 36de1395..45020238 100644 --- a/benchmarks/solana-dex-quote-latency.yml +++ b/benchmarks/solana-dex-quote-latency.yml @@ -70,7 +70,7 @@ dimensions: - { value: all, label: All regions } - { value: us-east, label: US-East } - { value: eu-west, label: EU-West } - - { value: sgp, label: AP-Southeast } + - { value: sgp, label: Singapore } faq: - q: "Which Solana DEX has the fastest quote API right now?" diff --git a/src/lib/brand.ts b/src/lib/brand.ts index 5a2404cb..c3ff20d5 100644 --- a/src/lib/brand.ts +++ b/src/lib/brand.ts @@ -47,13 +47,21 @@ const BRANDS: Record = { "us-east": { color: "#C7833A" }, "eu-west": { color: "#3A7BC7" }, "ap-southeast": { color: "#3F8F66" }, + // `sgp` is the raw Railway region label emitted by the harnesses; the bench + // YAML uses it as the dimension value, so we alias the same color here so + // the region tab gets a globe glyph + colored ring instead of a no-brand + // fallback. + sgp: { color: "#3F8F66" }, global: { color: "#7a7166" }, // ─── Aggregators / providers (bright, saturated - read on both modes) ─── mobula: { color: "#FF6B35" }, // vivid orange codex: { color: "#84cc16" }, // saturated lime - readable on white + dark geckoterminal: { color: "#8B5CF6" }, // vivid violet (gecko brand) - jupiter: { color: "#FF7A00" }, // amber-orange + jupiter: { color: "#C7F284" }, // jupiter matrix green (secondary brand) - keeps it + // distinct from mobula's orange on solana-dex-quote + raydium: { color: "#00C2FF" }, // raydium cyan + openocean: { color: "#2563EB" }, // openocean deep blue helius: { color: "#FF4D8D" }, // hot pink (helius brand) moralis: { color: "#5B89FF" }, // azure blockscout: { color: "#3DD7C8" }, // bright cyan @@ -76,7 +84,7 @@ const BRANDS: Record = { gains: { color: "#10E2A4" }, // emerald }; -const REGION_VALUES = new Set(["us-east", "eu-west", "ap-southeast", "global"]); +const REGION_VALUES = new Set(["us-east", "eu-west", "ap-southeast", "sgp", "global"]); /** Is this slug a region value? Region tabs render with a unified globe * glyph instead of a per-entity logo, so callers can branch on this. */ From 042f38e65bc9cd397646de7e7ba51da3abec2a23 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 31 May 2026 17:13:56 +0300 Subject: [PATCH 086/174] fix(bench): hide chart's internal region tabs when bench declares dimensions.region (#214) When a bench YAML has dimensions.region, the top-level Region row already controls region scoping. The TimeSeriesChart was still rendering its own internal region tabs because the page only forwarded the region prop when showChartRegionRow was true (the chart-only fallback path). Now we pass the top-level region selection down whenever regionOptions exist, so the chart hides its tabs and matches what the page-level filter selects. Also rename the AP-Southeast chart-tab label to 'Singapore' to match the top-level row, and add an alias for the raw 'sgp' region key. --- src/components/benchmark-body.tsx | 8 +++++++- src/components/time-series-chart.tsx | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index ca238fe9..c58fa880 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -337,7 +337,13 @@ export function BenchmarkBody({ {view === "timeseries" && ( 0 + ? (region ?? fallbackRegion ?? undefined) + : showChartRegionRow + ? chartRegion + : undefined + } excluded={excluded} onToggleExclude={toggleExclude} onResetExcluded={resetExcluded} diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index ab3f299a..ab3421ba 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -48,7 +48,8 @@ const RANGE_LABEL: Record = { const REGION_LABEL: Record = { "us-east": "US-East", "eu-west": "EU-West", - "ap-southeast": "AP-Southeast", + "ap-southeast": "Singapore", + sgp: "Singapore", global: "Global", }; From b81059ae8c19bdacc15c654942fc5eb5d3e98490 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 31 May 2026 17:19:14 +0300 Subject: [PATCH 087/174] site-header: 3D logo on desktop, SVG on touch (#215) --- package.json | 3 + pnpm-lock.yaml | 203 +++++++++++++ src/components/site-header.tsx | 24 +- src/components/site-logo-3d.tsx | 399 ++++++++++++++++++++++++++ src/components/site-logo-switcher.tsx | 41 +++ 5 files changed, 659 insertions(+), 11 deletions(-) create mode 100644 src/components/site-logo-3d.tsx create mode 100644 src/components/site-logo-switcher.tsx diff --git a/package.json b/package.json index ff2dbcba..a548de37 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "@react-three/fiber": "^9.6.1", "js-yaml": "^4.1.1", "lucide-react": "^1.11.0", "mcp-handler": "^1.1.0", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", + "three": "^0.184.0", "zod": "^4.3.6" }, "devDependencies": { @@ -31,6 +33,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.184.1", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6496de2a..72ee3b83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.26.0(zod@4.3.6) + '@react-three/fiber': + specifier: ^9.6.1 + version: 9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0) js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -32,6 +35,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + three: + specifier: ^0.184.0 + version: 0.184.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -54,6 +60,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.184.1 + version: 0.184.1 eslint: specifier: ^9 version: 9.39.4(jiti@2.6.1) @@ -131,6 +140,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -143,6 +156,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -628,6 +644,31 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -755,6 +796,9 @@ packages: '@tailwindcss/postcss@4.2.4': resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -781,9 +825,23 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.184.1': + resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.59.1': resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1043,6 +1101,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.23: resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} engines: {node: '>=6.0.0'} @@ -1068,6 +1129,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types@1.3.14: resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} @@ -1451,6 +1515,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1600,6 +1667,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1747,6 +1817,11 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1930,6 +2005,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2138,6 +2216,15 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -2325,6 +2412,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} @@ -2332,6 +2424,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2420,6 +2515,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2476,6 +2576,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2557,6 +2675,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2580,6 +2700,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2929,6 +3051,26 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.184.0 + use-sync-external-store: 1.6.0(react@19.2.4) + zustand: 5.0.14(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -3030,6 +3172,8 @@ snapshots: postcss: 8.5.12 tailwindcss: 4.2.4 + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3055,10 +3199,27 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.184.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3329,6 +3490,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.23: {} body-parser@2.2.2: @@ -3366,6 +3529,11 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bun-types@1.3.14: dependencies: '@types/node': 20.19.39 @@ -3919,6 +4087,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4071,6 +4241,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4221,6 +4393,13 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' + jiti@2.6.1: {} jose@6.2.3: {} @@ -4359,6 +4538,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -4563,6 +4744,12 @@ snapshots: react-is@16.13.1: {} + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} redis@4.7.1: @@ -4845,10 +5032,16 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + suspend-react@0.1.3(react@19.2.4): + dependencies: + react: 19.2.4 + tailwindcss@4.2.4: {} tapable@2.3.3: {} + three@0.184.0: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -4981,6 +5174,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vary@1.1.2: {} which-boxed-primitive@1.1.1: @@ -5047,3 +5244,9 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index f3eadeec..f66aa194 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -4,7 +4,7 @@ import { Menu, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { SiteBanner } from "@/components/site-banner"; -import { SiteLogo } from "@/components/site-logo"; +import { SiteLogoSwitcher } from "@/components/site-logo-switcher"; import { ThemeToggle } from "@/components/theme-toggle"; function GithubIcon({ size = 15 }: { size?: number }) { @@ -43,16 +43,18 @@ export function SiteHeader() {
- setOpen(false)} - > - - - OpenChainBench - - +
+ + setOpen(false)} + > + + OpenChainBench + + +
{RANGES.map((r) => { const active = r === range; - const disabled = r === "7d" && !has7d; + const disabled = + (r === "7d" && !has7d) || (r === "30d" && !has30d); return ( + + ); + })} + + {onResetExcluded && ( + + )} +