From 0e18ee69fcd4d71319595c58f81112821c12b3c1 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Mon, 1 Jun 2026 19:14:39 +0200 Subject: [PATCH] feat(bench): switchable companion metric panels under main ledger Spec authors can now declare any number of `metric_panels` (up to 8) in a bench YAML. Each panel names one Prometheus metric; the loader queries it per provider as `{=""}` and stores the scalar values. A new MetricPanelGrid component renders below the main table with a tab row at the top; clicking a tab re-ranks the cohort by that panel's metric, applying its higher_is_better direction. Hyperliquid frontends ships first with 5 panels: slippage proxy, time since last fill (outage), fills per minute (activity), taker share, and volume routed. All values come from the local hl-node v2 harness at sub-minute freshness. Non-breaking: benches without `metric_panels` render exactly as before. Cardinality impact on Prom is bounded (5 metrics x 8 providers = 40 new series across all panels for this bench). --- benchmarks/hyperliquid-frontends.yml | 37 ++++++ src/components/benchmark-body.tsx | 7 ++ src/components/metric-panel-grid.tsx | 179 +++++++++++++++++++++++++++ src/lib/spec-schema.ts | 31 ++++- src/lib/spec.ts | 30 ++++- src/types/benchmark.ts | 17 +++ 6 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/components/metric-panel-grid.tsx diff --git a/benchmarks/hyperliquid-frontends.yml b/benchmarks/hyperliquid-frontends.yml index d610fd69..dc0565bb 100644 --- a/benchmarks/hyperliquid-frontends.yml +++ b/benchmarks/hyperliquid-frontends.yml @@ -243,3 +243,40 @@ providers: success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) sample_size: hl_frontend_fills_total_24h_v2{builder="okto"} series: hl_frontend_effective_fee_bps_v2{builder="okto"} + +# Companion metric panels surfaced as a switchable mini leaderboard below +# the main p50/p90/p99 table. Each panel queries one Prom metric per +# provider (builder label) and re-ranks the cohort under its direction. +# All five metrics come from the local hl-node harness and update at the +# same sub-minute cadence as the main headline. +metric_panels: + - id: deviation + label: Slippage proxy + description: "Per fill, the basis-point gap between the executed price and the previous trade price on the same asset, averaged over the rolling 24h window. Lower is better. Frontends that route to less liquid venues or slower order paths show higher deviation, which is direct user cost on top of the fee column." + metric: hl_frontend_price_deviation_bps_v2 + unit: bps + higher_is_better: false + - id: outage + label: Time since last fill + description: "Seconds since this builder's most recent attributed fill landed on Hyperliquid. Cohort baseline runs under one minute during active hours; above a few minutes is a real anomaly. Catches frontends whose routing pipeline is down or quietly stopped operating." + metric: hl_frontend_last_fill_age_seconds_v2 + unit: s + higher_is_better: false + - id: activity + label: Fills per minute + description: "Activity rate over the rolling 24h window. Higher means more flow currently routes through this frontend. Combine with effective fee bps to read the operational intent: high activity + low fee = aligned, high activity + high fee = extractive." + metric: hl_frontend_fills_per_min_v2 + unit: count + higher_is_better: true + - id: taker + label: Taker share + description: "Fraction of fills that crossed the spread (paid the bid ask gap). Aggressive instant execution wallets skew taker heavy, pro terminals with limit order workflows skew maker heavy. Reveals the routing intent embedded in each frontend's UX." + metric: hl_frontend_taker_pct_v2 + unit: pct + higher_is_better: false + - id: volume + label: Volume routed + description: "Notional USD routed in the rolling 24h window. Headline volume metric most other HL dashboards publish. Here it is companion, not the headline, because volume is only loosely correlated with how aligned a frontend is with its users." + metric: hl_frontend_volume_usd_24h_v2 + unit: usd + higher_is_better: true diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index c58fa880..80c86846 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -11,6 +11,7 @@ import { RankedBarChart } from "@/components/ranked-bar-chart"; import { DistributionChart } from "@/components/distribution-chart"; import { DonutChart } from "@/components/donut-chart"; import { RegionGrid } from "@/components/region-grid"; +import { MetricPanelGrid } from "@/components/metric-panel-grid"; import { CountLeaderboard } from "@/components/count-leaderboard"; import { SummaryStat } from "@/components/summary-stat"; import { ViewSwitcher } from "@/components/view-switcher"; @@ -362,6 +363,12 @@ export function BenchmarkBody({ + {benchmark.metricPanels && benchmark.metricPanels.length > 0 && ( +
+ +
+ )} + {benchmark.unit !== "count" && Object.keys(benchmark.extras.regions).length > 0 && (
diff --git a/src/components/metric-panel-grid.tsx b/src/components/metric-panel-grid.tsx new file mode 100644 index 00000000..05246a2a --- /dev/null +++ b/src/components/metric-panel-grid.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import type { Benchmark, MetricPanel } from "@/types/benchmark"; +import { fmtUnit } from "@/lib/format"; + +/** + * Switchable mini leaderboard surfaced under the main ledger. + * + * Each panel declares one Prom metric (already fetched server side and + * stored in `benchmark.metricPanels`). The reader toggles between panels; + * the visible table re-ranks the same provider set by the active panel's + * metric, applying its higher_is_better direction. + * + * Renders nothing when `benchmark.metricPanels` is empty, so non-HL + * benches that do not declare any panels stay unaffected. + */ +export function MetricPanelGrid({ benchmark }: { benchmark: Benchmark }) { + const panels = benchmark.metricPanels ?? []; + const [activeId, setActiveId] = useState(panels[0]?.id ?? null); + + if (panels.length === 0) return null; + + const active = panels.find((p) => p.id === activeId) ?? panels[0]; + + return ( +
+
+
+

Companion metrics

+ {active.description && ( +

+ {active.description} +

+ )} +
+ +
+ + +
+ ); +} + +function PanelTabs({ + panels, + active, + onSelect, +}: { + panels: MetricPanel[]; + active: string; + onSelect: (id: string) => void; +}) { + return ( +
+ {panels.map((p) => { + const on = p.id === active; + return ( + + ); + })} +
+ ); +} + +function MetricPanelTable({ + panel, + benchmark, +}: { + panel: MetricPanel; + benchmark: Benchmark; +}) { + // Build rows from the live provider set so unknown/extra slugs in + // panel.values get ignored (defensive against a stale label rotation). + const rows = benchmark.results + .map((r) => ({ + slug: r.slug, + name: r.name, + value: panel.values[r.slug], + })) + .filter((r) => Number.isFinite(r.value)); + + rows.sort((a, b) => + panel.higherIsBetter ? b.value - a.value : a.value - b.value + ); + + const noData = benchmark.results.filter( + (r) => !Number.isFinite(panel.values[r.slug]) + ); + + if (rows.length === 0) { + return ( +

+ Currently no data for this metric. Will populate within ~1 hour once + the harness fills its rolling window. +

+ ); + } + + return ( +
+ + + + + + + + + + {rows.map((row, i) => ( + + + + + + ))} + {noData.map((p) => ( + + + + + + ))} + +
+ Provider + + {panel.label} + + Rank +
{row.name} + {formatPanelValue(row.value, panel.unit)} + + #{i + 1} +
{p.name}no data + {"—"} +
+
+ ); +} + +function formatPanelValue( + value: number, + unit: MetricPanel["unit"] +): string { + if (unit === "pct") { + // Detect both 0..1 fractions and already-scaled percentages. + const v = value > 1 ? value : value * 100; + return `${v.toFixed(1)}%`; + } + if (unit === "count") { + if (value >= 1000) return value.toFixed(0); + if (value >= 10) return value.toFixed(1); + return value.toFixed(2); + } + if (unit === "s") { + if (value >= 3600) return `${(value / 3600).toFixed(1)}h`; + if (value >= 60) return `${(value / 60).toFixed(1)}min`; + return `${value.toFixed(0)}s`; + } + return fmtUnit(value, unit); +} diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index e58dc4d6..743cc9bb 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -69,7 +69,7 @@ const slug = z /** PromQL. a non-empty string. We don't parse PromQL; that's Prometheus's job. */ const promql = z.string().min(1); -const region = z.enum(["us-east", "eu-west", "ap-southeast", "global"]); +const region = z.enum(["us-east", "eu-west", "ap-southeast", "sgp", "global"]); const queries = z .object({ @@ -274,6 +274,35 @@ export const SpecSchema = z .optional(), providers: z.array(provider).min(1), + + /** + * Optional companion metrics rendered as switchable mini leaderboards + * below the main table. Each panel declares a single Prometheus metric + * name; the spec loader queries it per provider as + * `{builder=""}` and stores the value. Used by the HL + * frontends bench to surface execution quality, outage signal, taker + * share, etc. without changing the main p50 headline. + */ + metric_panels: z + .array( + z.object({ + id: z + .string() + .min(1) + .max(40) + .regex(/^[a-z][a-z0-9_]*$/, "Panel id must be lowercase snake_case"), + label: z.string().min(1).max(80), + description: z.string().max(300).optional(), + metric: z.string().min(1).max(200), + /** The PromQL label that holds each provider's slug. Defaults to + * "builder"; other benches may use "provider", "venue", etc. */ + label_key: z.string().min(1).max(40).default("builder"), + unit: z.enum(["ms", "s", "pct", "bps", "count", "slots", "usd"]), + higher_is_better: z.boolean().default(false), + }) + ) + .max(8) + .optional(), }) .strict(); diff --git a/src/lib/spec.ts b/src/lib/spec.ts index a324305c..0dc54af4 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -14,7 +14,7 @@ import path from "node:path"; import { cache } from "react"; import { unstable_cache } from "next/cache"; import yaml from "js-yaml"; -import type { Benchmark, ProviderResult } from "@/types/benchmark"; +import type { Benchmark, MetricPanel, ProviderResult } from "@/types/benchmark"; import { Prometheus } from "@/lib/prometheus"; import { SpecSchema, type Spec } from "@/lib/spec-schema"; import { renderBenchmarkText } from "@/lib/bench-template"; @@ -516,7 +516,7 @@ function escapePromLabelValue(v: string): string { async function tryLoadLive( spec: Spec, isFiltered = false -): Promise | null> { +): Promise | null> { const url = spec.prometheus?.url ?? process.env.PROMETHEUS_URL; if (!url) return null; const prom = new Prometheus(url); @@ -643,6 +643,31 @@ async function tryLoadLive( // No live numbers from anyone (every provider was skipped) → draft. if (liveResults.length === 0) return null; + // Optional companion metric panels. Each panel declares one Prometheus + // metric; we query it per provider (`{=""}`) + // and store the scalar values. Providers with no data for that metric + // are omitted from the values map (rendered as "no data" by the UI). + const metricPanels: MetricPanel[] = []; + for (const panel of spec.metric_panels ?? []) { + const values: Record = {}; + await Promise.all( + spec.providers.map(async (p) => { + const q = `${panel.metric}{${panel.label_key}="${escapePromLabelValue(p.slug)}"}`; + const v = await prom.scalar(q); + if (v != null && Number.isFinite(v)) values[p.slug] = v; + }) + ); + metricPanels.push({ + id: panel.id, + label: panel.label, + description: panel.description, + metric: panel.metric, + unit: panel.unit, + higherIsBetter: panel.higher_is_better, + values, + }); + } + // Derive lastRunAt from the actual Prom data freshness. We probe the // first provider that has a p50 query and ask Prom for the age of // its underlying metric. This is consistent across pages (Prom is the @@ -673,6 +698,7 @@ async function tryLoadLive( Object.keys(seriesByRegion30d).length > 0 ? seriesByRegion30d : undefined, regions: regions as Benchmark["extras"]["regions"], }, + metricPanels: metricPanels.length > 0 ? metricPanels : undefined, sampleSize: totalSamples, lastRunAt, }; diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 8d2c061d..04a3c7d7 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -57,6 +57,19 @@ export type RegionPoint = { export type Series24h = number[]; +export type MetricPanel = { + id: string; + label: string; + description?: string; + metric: string; + unit: "ms" | "s" | "pct" | "bps" | "count" | "slots" | "usd"; + higherIsBetter: boolean; + /** Per-provider scalar values, keyed by provider slug. Providers with no + * live data for this metric are omitted; the renderer renders them as + * "no data" instead of zero. */ + values: Record; +}; + export type ResultExtras = { /** 24h-window global series per provider. sparklines + default chart view. */ series24h: Record; @@ -147,4 +160,8 @@ export type Benchmark = { methodology: string[]; source: string; extras: ResultExtras; + /** Optional companion metrics surfaced as switchable mini leaderboards + * below the main table. Populated by the spec loader from + * `metric_panels` in the YAML. */ + metricPanels?: MetricPanel[]; };