From 41a87cd8e0c4f3605104e8b4b16c9c23646ec2ca Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:29:53 +0200 Subject: [PATCH 1/5] prom: emit dense series with nulls for empty buckets --- src/lib/materialize/load.ts | 18 ++++---- src/lib/prometheus.ts | 92 ++++++++++++++++++++++++++----------- src/lib/snapshot.ts | 10 ++-- src/types/benchmark.ts | 16 +++++-- 4 files changed, 92 insertions(+), 44 deletions(-) diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 17fc8633..8c19ed90 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -643,12 +643,12 @@ async function tryLoadLive( try { const liveResults: ProviderResult[] = []; - const series24h: Record = {}; - const series7d: Record = {}; - const series30d: Record = {}; - const seriesByRegion24h: Record> = {}; - const seriesByRegion7d: Record> = {}; - const seriesByRegion30d: Record> = {}; + const series24h: Record = {}; + const series7d: Record = {}; + const series30d: Record = {}; + const seriesByRegion24h: Record> = {}; + const seriesByRegion7d: Record> = {}; + const seriesByRegion30d: Record> = {}; const regions: Record = {}; let totalSamples = 0; const sevenDaysSec = 7 * 86_400; @@ -841,9 +841,9 @@ async function tryLoadLive( const metricPanels: MetricPanel[] = []; for (const panel of spec.metric_panels ?? []) { const values: Record = {}; - const seriesByProvider: Record = {}; - const seriesByProvider7d: Record = {}; - const seriesByProvider30d: Record = {}; + const seriesByProvider: Record = {}; + const seriesByProvider7d: Record = {}; + const seriesByProvider30d: Record = {}; await Promise.all( spec.providers.map(async (p) => { const sel = `${panel.label_key}="${escapePromLabelValue(p.slug)}"`; diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index 5c6b58e1..189b8752 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -130,43 +130,36 @@ export class Prometheus { /** Convenience: an evenly-spaced numeric series for the last `windowSec` seconds. * Default 72 points = 20-min resolution over a 24h window. * + * The output is DENSE: one slot per query_range evaluation step + * (start + k*step), with `null` where Prom returned no sample. Prom + * silently omits empty evaluation buckets from the matrix, and the old + * bare-array output shifted every consumer that back-computes + * timestamps as `now - (N-1-i)*step` — a 19h outage on + * aggregator-head-lag 7d made the chart start at -6d6h instead of -7d + * and squeezed the gap out of view entirely. + * * If the query returns multiple series (e.g. one per route × token × region * for the same provider), the values are averaged at each timestamp so the * caller gets a single coherent line. Without this, providers with broader * coverage would silently drop because we used to keep only the first * matching series and that one was often sparse / NaN. */ - async series(promql: string, windowSec: number, points = 72): Promise { + async series( + promql: string, + windowSec: number, + points = 72, + ): Promise<(number | null)[] | null> { try { const end = new Date(); const start = new Date(end.getTime() - windowSec * 1000); const step = Math.max(1, Math.floor(windowSec / points)); const res = await this.queryRange(promql, start, end, step); if (res.result.length === 0) return null; - - // Build a timestamp → [values] map across all returned series. - const buckets = new Map(); - for (const series of res.result) { - for (const [ts, raw] of series.values) { - const v = Number(raw); - if (!Number.isFinite(v)) continue; - const list = buckets.get(ts) ?? []; - list.push(v); - buckets.set(ts, list); - } - } - - // Average values per timestamp, ordered chronologically. Rounded to - // 6 significant digits: raw averages carry 15+ digit tails that - // bloated the hyperliquid-frontends bench past unstable_cache's 2MB - // limit ("items over 2MB can not be cached"), so its cache NEVER - // persisted and every render redid the full Prom fan-out with no - // previous-value fallback. - const ordered = Array.from(buckets.entries()).sort((a, b) => a[0] - b[0]); - const out = ordered.map(([, vs]) => { - const mean = vs.reduce((s, v) => s + v, 0) / vs.length; - return mean === 0 ? 0 : Number(mean.toPrecision(6)); - }); - return out.length > 0 ? out : null; + return denseSeriesFromMatrix( + res.result, + start.getTime() / 1000, + end.getTime() / 1000, + step, + ); } catch { return null; } @@ -215,6 +208,53 @@ export class Prometheus { } } +/** Map a query_range matrix onto the dense evaluation grid + * `startSec + k*stepSec` (k = 0..floor((endSec-startSec)/stepSec)) and + * emit one entry per grid slot: the mean of every sample that landed in + * that slot, or `null` when Prom emitted nothing there. + * + * Values are rounded to 6 significant digits: raw averages carry 15+ + * digit tails that bloated the hyperliquid-frontends bench past + * unstable_cache's 2MB limit ("items over 2MB can not be cached"), so + * its cache NEVER persisted and every render redid the full Prom + * fan-out with no previous-value fallback. + * + * Returns null when no finite sample maps onto the grid at all (keeps + * the caller's `null = no data` semantics). + * + * Pure and exported for unit tests. */ +export function denseSeriesFromMatrix( + result: PromMatrix[], + startSec: number, + endSec: number, + stepSec: number, +): (number | null)[] | null { + const slots = Math.floor((endSec - startSec) / stepSec) + 1; + if (slots <= 0) return null; + const buckets: number[][] = Array.from({ length: slots }, () => []); + for (const series of result) { + for (const [ts, raw] of series.values) { + const v = Number(raw); + if (!Number.isFinite(v)) continue; + const idx = Math.round((ts - startSec) / stepSec); + if (idx < 0 || idx >= slots) continue; + // Reject samples that don't sit on the grid (defensive: query_range + // only evaluates at grid timestamps, so anything off-grid means the + // caller's start/step don't match the response). + if (Math.abs(ts - (startSec + idx * stepSec)) > stepSec / 2) continue; + buckets[idx].push(v); + } + } + let any = false; + const out = buckets.map((vs) => { + if (vs.length === 0) return null; + any = true; + const mean = vs.reduce((s, v) => s + v, 0) / vs.length; + return mean === 0 ? 0 : Number(mean.toPrecision(6)); + }); + return any ? out : null; +} + /** Module-level semaphore for fetchEnvelope. * * Sizing matters more than it looks: at 8 slots a bench page that diff --git a/src/lib/snapshot.ts b/src/lib/snapshot.ts index 2ee37634..89e658bd 100644 --- a/src/lib/snapshot.ts +++ b/src/lib/snapshot.ts @@ -93,7 +93,9 @@ const RegionPointSchema = z.object({ p50: z.number(), }); -const Series24hSchema = z.array(z.number()); +// Nullable entries: dense series carry `null` for empty Prom buckets +// (gap rendering). Pre-null blobs (bare number arrays) parse unchanged. +const Series24hSchema = z.array(z.number().nullable()); const ResultExtrasSchema = z.object({ series24h: z.record(z.string(), Series24hSchema), @@ -121,9 +123,9 @@ const MetricPanelSchema = z.object({ tab: z.boolean().optional(), values: z.record(z.string(), z.number()), valuesMeta: z.record(z.string(), StalenessMetaSchema).optional(), - seriesByProvider: z.record(z.string(), z.array(z.number())).optional(), - seriesByProvider7d: z.record(z.string(), z.array(z.number())).optional(), - seriesByProvider30d: z.record(z.string(), z.array(z.number())).optional(), + seriesByProvider: z.record(z.string(), Series24hSchema).optional(), + seriesByProvider7d: z.record(z.string(), Series24hSchema).optional(), + seriesByProvider30d: z.record(z.string(), Series24hSchema).optional(), }); const CellRankEntrySchema = z.object({ diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 63f49349..c213c3ef 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -104,7 +104,12 @@ export type RegionPoint = { p50: number; }; -export type Series24h = number[]; +/** Dense per-window series aligned to the Prom query_range grid. `null` + * marks an evaluation bucket with no sample (harness outage, provider + * offline) so renderers can draw an honest gap instead of silently + * compressing the X-axis. Older worker blobs predate the nulls and + * carry bare number arrays — a valid subset of this type. */ +export type Series24h = (number | null)[]; export type MetricPanel = { id: string; @@ -125,14 +130,15 @@ export type MetricPanel = { valuesMeta?: Record; /** Per-provider 24h time-series (72 points by default), keyed by * provider slug. Powers the multi-line chart view of the panel. - * Providers with no Prom data for the query are absent from the map. */ - seriesByProvider?: Record; + * Providers with no Prom data for the query are absent from the map. + * Entries are dense with nulls for empty buckets (see Series24h). */ + seriesByProvider?: Record; /** Per-provider 7 day time-series (84 points by default). Used by the * chart's 7D range tab when a panel is active. */ - seriesByProvider7d?: Record; + seriesByProvider7d?: Record; /** Per-provider 30 day time-series (60 points by default). Used by the * chart's 30D range tab when a panel is active. */ - seriesByProvider30d?: Record; + seriesByProvider30d?: Record; }; export type ResultExtras = { From c908717ec8078e8df054b9ad404958db18c5a8fb Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:34:15 +0200 Subject: [PATCH 2/5] charts: render null buckets as gaps, keep sparklines numbers-only --- scripts/dry-run-spec.ts | 14 +++++--- .../benchmarks/[slug]/share-card/route.tsx | 7 +++- src/components/ledger-table.tsx | 8 +++-- src/components/mini-chart.tsx | 2 +- src/components/sparkline.tsx | 21 ++++++++---- src/components/time-series-chart/chart.tsx | 32 +++++++++++++++---- src/components/time-series-chart/index.tsx | 20 ++++++------ src/components/time-series-chart/scales.ts | 12 ++++--- src/components/time-series-chart/series.tsx | 9 +++--- src/lib/citation.ts | 8 +++-- src/lib/downsample.ts | 15 +++++++-- src/lib/rpc-hub-stats.ts | 6 +++- worker/index.ts | 2 +- 13 files changed, 107 insertions(+), 49 deletions(-) diff --git a/scripts/dry-run-spec.ts b/scripts/dry-run-spec.ts index dce72efd..683de48a 100644 --- a/scripts/dry-run-spec.ts +++ b/scripts/dry-run-spec.ts @@ -94,13 +94,17 @@ async function main() { } continue; } - const min = Math.min(...s); - const max = Math.max(...s); - const last = s[s.length - 1]; - const meanV = s.reduce((a, b) => a + b, 0) / s.length; + // Dense series carry nulls for empty Prom buckets; stats read the + // real samples only, but report the gap count alongside. + const present = s.filter((v): v is number => v != null); + const gaps = s.length - present.length; + const min = Math.min(...present); + const max = Math.max(...present); + const last = present[present.length - 1]; + const meanV = present.reduce((a, b) => a + b, 0) / Math.max(1, present.length); console.log( ` ${p.slug.padEnd(18)} ` + - `points=${s.length} ` + + `points=${s.length}${gaps > 0 ? ` (${gaps} empty)` : ""} ` + `min=${fmt(min)} max=${fmt(max)} mean=${fmt(meanV)} last=${fmt(last)}` ); } diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index d462c280..3ecc6c15 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -925,7 +925,12 @@ async function renderSnapshot( .map((r) => ({ slug: r.slug, name: r.name, - values: benchmark.extras.series24h[r.slug] ?? [], + // Dense series carry nulls for empty Prom buckets. The OG snapshot + // is a static thumbnail, so skip them (connect across the gap) + // rather than break the polyline. + values: (benchmark.extras.series24h[r.slug] ?? []).filter( + (v): v is number => v != null, + ), color: colors.get(r.slug) ?? INK_SOFT, p50: r.ms.p50, })) diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index 71749795..b98c53de 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -309,7 +309,11 @@ export function LedgerTable({ // min/max projects panel values wildly out of bounds and the trend // column renders vertical streaks running off the row. const sparkSource = activePanel?.seriesByProvider ?? extras.series24h; - const allSeries = Object.values(sparkSource).flat(); + // Nulls (empty Prom buckets in dense series) carry no magnitude and + // must not touch the shared sparkline scale. + const allSeries = Object.values(sparkSource) + .flat() + .filter((v): v is number => v != null); const sparkMin = allSeries.length ? Math.min(...allSeries) : 0; const sparkMax = allSeries.length ? Math.max(...allSeries) : 1; @@ -676,7 +680,7 @@ function Row({ /** Custom-column mode (benchmark.ledgerColumns): one pre-resolved * {value, unit} per declared column, replacing p50/p90/p99/Mean. */ customCells?: { v: number | null; unit: string }[]; - series: number[]; + series: (number | null)[]; sparkMin: number; sparkMax: number; color: string; diff --git a/src/components/mini-chart.tsx b/src/components/mini-chart.tsx index b2d4afa5..42de2957 100644 --- a/src/components/mini-chart.tsx +++ b/src/components/mini-chart.tsx @@ -17,7 +17,7 @@ import { buildProviderColors } from "@/lib/series-colors"; type MiniChartBenchmark = { results: { slug: string; name: string; ms: { p50: number } }[]; higherIsBetter: boolean; - extras: { series24h: Record }; + extras: { series24h: Record }; }; type Props = { diff --git a/src/components/sparkline.tsx b/src/components/sparkline.tsx index f867fa1c..87cc93ef 100644 --- a/src/components/sparkline.tsx +++ b/src/components/sparkline.tsx @@ -1,5 +1,9 @@ type Props = { - values: number[]; + /** Dense series; `null` marks an empty Prom bucket. Nulls are skipped + * (the polyline connects across them). At 92x22 px a broken segment + * reads as noise, so compressing over gaps is the honest-enough + * cheap option for the trend column. */ + values: (number | null)[]; width?: number; height?: number; color?: string; @@ -15,20 +19,23 @@ export function Sparkline({ globalMax, globalMin, }: Props) { - if (!values.length) return null; - const min = globalMin ?? Math.min(...values); - const max = globalMax ?? Math.max(...values); + const present = values.filter( + (v): v is number => v != null && Number.isFinite(v), + ); + if (!present.length) return null; + const min = globalMin ?? Math.min(...present); + const max = globalMax ?? Math.max(...present); const range = max - min || 1; - const points = values + const points = present .map((v, i) => { - const x = (i / (values.length - 1)) * width; + const x = (i / Math.max(1, present.length - 1)) * width; const y = height - ((v - min) / range) * height; return `${x.toFixed(2)},${y.toFixed(2)}`; }) .join(" "); - const last = values[values.length - 1]; + const last = present[present.length - 1]; const lastY = height - ((last - min) / range) * height; return ( diff --git a/src/components/time-series-chart/chart.tsx b/src/components/time-series-chart/chart.tsx index bb16fd54..6998b116 100644 --- a/src/components/time-series-chart/chart.tsx +++ b/src/components/time-series-chart/chart.tsx @@ -67,8 +67,18 @@ export function Chart({ // outlier (e.g. GeckoTerminal at 11s while the others sit under 1s) // lets the remaining lines spread out. If everything is excluded we // fall back to the full set so the axis doesn't collapse. - const visibleValues = slicedLines.filter((l) => !l.excluded).flatMap((l) => l.values); - const sourceValues = visibleValues.length > 0 ? visibleValues : slicedLines.flatMap((l) => l.values); + // Nulls are empty Prom buckets (gaps); they carry no magnitude and + // must not touch the Y domain (Math.min would coerce null to 0 and + // pin the axis floor). + const finiteOnly = (vs: (number | null)[]) => + vs.filter((v): v is number => v != null && Number.isFinite(v)); + const visibleValues = finiteOnly( + slicedLines.filter((l) => !l.excluded).flatMap((l) => l.values), + ); + const sourceValues = + visibleValues.length > 0 + ? visibleValues + : finiteOnly(slicedLines.flatMap((l) => l.values)); const dataMin = Math.min(...sourceValues); const dataMax = Math.max(...sourceValues); const targetTicks = niceTicks(dataMin, dataMax, 4); @@ -207,9 +217,12 @@ export function Chart({ const expected = Math.max(1, expectedPoints - 1); return slicedLines.map((l) => { const color = l.color; - const positive = l.values.filter((v) => v > 0); + const positive = l.values.filter((v): v is number => v != null && v > 0); const positiveMin = positive.length > 0 ? Math.min(...positive) : 0; - const isGap = (v: number) => !Number.isFinite(v) || (v === 0 && positiveMin > 1); + // Null = empty Prom bucket, always a gap. The zero heuristic stays + // for pre-null blobs where an outage was recorded as hard zeroes. + const isGap = (v: number | null) => + v == null || !Number.isFinite(v) || (v === 0 && positiveMin > 1); const lastIdx = Math.max(0, l.values.length - 1); // If Prom returned more points than the chart was sized for (off-by-one @@ -223,7 +236,9 @@ export function Chart({ const pts = l.values.map((v, i) => { const offsetFromRight = (lastIdx - i) / denom; const x = padL + innerW * (1 - offsetFromRight); - const y = padT + innerH * (1 - (v - lo) / yRange); + // Gap points are never drawn; anchor their y at the domain floor + // so the coordinate stays finite. + const y = padT + innerH * (1 - ((v ?? lo) - lo) / yRange); return { x, y, gap: isGap(v) } as const; }); @@ -269,7 +284,7 @@ export function Chart({ if (lastDrawn) closeSegment(lastDrawn.x); // End-of-line label uses the last non-gap value. - const last = lastDrawn ? l.values[pts.indexOf(lastDrawn)] : 0; + const last = (lastDrawn ? l.values[pts.indexOf(lastDrawn)] : 0) ?? 0; const lastX = lastDrawn ? lastDrawn.x : padL + innerW; const lastY = lastDrawn ? lastDrawn.y : padT + innerH; // Expose isGap so the hover dot + tooltip can drop a sample that @@ -325,7 +340,10 @@ export function Chart({ const value = d.values[localIdx]; return { ...d, value }; }) - .filter((d) => Number.isFinite(d.value) && !d.isGap(d.value)) + .filter( + (d): d is (typeof d) & { value: number } => + d.value != null && Number.isFinite(d.value) && !d.isGap(d.value), + ) .sort((a, b) => b.value - a.value); }, [drawn, hover, numPoints]); diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index 1e941305..6e105ae1 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -42,11 +42,11 @@ type Props = { * `benchmark.extras.series24h[slug]`, swaps the metric name in the * header, and switches the Y-axis unit. Used by the bench page when * the reader selects a companion metric from the panel tab row. */ - seriesOverride?: Record; + seriesOverride?: Record; /** Optional 7 day and 30 day variants of the panel override. The chart * picks the matching one when the range tab is 7d or 30d. */ - seriesOverride7d?: Record; - seriesOverride30d?: Record; + seriesOverride7d?: Record; + seriesOverride30d?: Record; metricLabelOverride?: string; unitOverride?: Benchmark["unit"]; /** Direction override for ranking when a metric panel is active. Bench @@ -133,8 +133,8 @@ export function TimeSeriesChart({ // rest of the session. CDN cache-control on /api/series (60 s // s-maxage + 300 s SWR) absorbs concurrent visitors so Prom sees at // most one fan-out per (bench, range) per minute. - const [lazySeries7d, setLazySeries7d] = useState | null>(null); - const [lazySeries30d, setLazySeries30d] = useState | null>(null); + const [lazySeries7d, setLazySeries7d] = useState | null>(null); + const [lazySeries30d, setLazySeries30d] = useState | null>(null); // Pre-fetch 7d AND 30d in the background as soon as the chart mounts, // not just when the user clicks the tab. The fetches are non-blocking @@ -163,10 +163,10 @@ export function TimeSeriesChart({ if (cancelled || done[range]) return; fetch(`/api/series/${benchmark.slug}?${buildQs(range)}`) .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) - .then((data: { providers: { slug: string; values: number[] }[] }) => { + .then((data: { providers: { slug: string; values: (number | null)[] }[] }) => { if (cancelled) return; done[range] = true; - const map: Record = {}; + const map: Record = {}; for (const p of data.providers) map[p.slug] = p.values; if (range === "7d") setLazySeries7d(map); else setLazySeries30d(map); @@ -236,13 +236,13 @@ export function TimeSeriesChart({ // the matching variant if it exists. Sub 24h tabs slice the 24h // variant trailing edge. Long range tabs fall back to the 24h // variant when the longer one is missing (older specs). - const pickPanel = (): Record | undefined => { + const pickPanel = (): Record | undefined => { if (range === "30d" && seriesOverride30d) return seriesOverride30d; if (range === "7d" && seriesOverride7d) return seriesOverride7d; return seriesOverride; }; const panel = pickPanel(); - const sliceOverride = (full: number[]): number[] => { + const sliceOverride = (full: (number | null)[]): (number | null)[] => { // Sub 24h ranges slice the 24h panel series trailing edge. if (range === "24h" || range === "7d" || range === "30d") return full; const ratio = RANGE_HOURS[range] / 24; @@ -255,7 +255,7 @@ export function TimeSeriesChart({ // long-window archive for the bench. // Pick from the lazy map when in those ranges, fall back to // pickSeries (which uses benchmark.extras.series24h) otherwise. - const pickBenchValues = (slug: string): number[] => { + const pickBenchValues = (slug: string): (number | null)[] => { if (isLongRange) { return longRangeSeries?.[range]?.[slug] ?? []; } diff --git a/src/components/time-series-chart/scales.ts b/src/components/time-series-chart/scales.ts index 025476de..876767bb 100644 --- a/src/components/time-series-chart/scales.ts +++ b/src/components/time-series-chart/scales.ts @@ -84,7 +84,8 @@ export type LineWithColor = { slug: string; name: string; color: string; - values: number[]; + /** Dense series; `null` marks an empty Prom bucket (rendered as a gap). */ + values: (number | null)[]; excluded: boolean; }; @@ -93,7 +94,7 @@ export function pickSeries( slug: string, range: Range, region: string -): number[] { +): (number | null)[] { const allRegion = isAll(region); if (!allRegion) { @@ -134,9 +135,10 @@ export function pickSeries( return s24.slice(-take); } -export function mean(xs: number[]): number { - if (!xs.length) return 0; - return xs.reduce((s, v) => s + v, 0) / xs.length; +export function mean(xs: (number | null)[]): number { + const present = xs.filter((v): v is number => v != null); + if (!present.length) return 0; + return present.reduce((s, v) => s + v, 0) / present.length; } /** diff --git a/src/components/time-series-chart/series.tsx b/src/components/time-series-chart/series.tsx index a28d25b7..22e685cd 100644 --- a/src/components/time-series-chart/series.tsx +++ b/src/components/time-series-chart/series.tsx @@ -8,7 +8,7 @@ export type DrawnLine = LineWithColor & { lastX: number; lastY: number; last: number; - isGap: (v: number) => boolean; + isGap: (v: number | null) => boolean; }; type SeriesPathsProps = { @@ -153,9 +153,10 @@ export function HoverMarkers({ const t = fract - i0; const v0 = d.values[i0]; const v1 = d.values[i1]; - // If either bracketing sample is a gap, the line itself is - // broken here (drawn as `M` not `L`) — skip the dot so we - // don't paint over a missing segment. + // If either bracketing sample is a gap (null bucket included), + // the line itself is broken here (drawn as `M` not `L`) — skip + // the dot so we don't paint over a missing segment. + if (v0 == null || v1 == null) return null; if (!Number.isFinite(v0) || !Number.isFinite(v1)) return null; if (d.isGap(v0) || d.isGap(v1)) return null; const v = v0 + (v1 - v0) * t; diff --git a/src/lib/citation.ts b/src/lib/citation.ts index 41d3aab9..ad3eb923 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -194,10 +194,14 @@ export function citeBundle( export function sparklineFor(b: Benchmark, providerSlug?: string): number[] { const series = b.extras.series24h ?? {}; const pick = providerSlug && series[providerSlug] ? series[providerSlug] : firstSeries(series); - return pick ?? []; + // Dense series carry nulls for empty Prom buckets; the citation JSON + // sparkline stays a plain number array for external consumers. + return (pick ?? []).filter((v): v is number => v != null); } -function firstSeries(s: Record): number[] | null { +function firstSeries( + s: Record, +): (number | null)[] | null { for (const k of Object.keys(s)) { const v = s[k]; if (v && v.length > 0) return v; diff --git a/src/lib/downsample.ts b/src/lib/downsample.ts index 5c0566a5..e154b7d4 100644 --- a/src/lib/downsample.ts +++ b/src/lib/downsample.ts @@ -5,8 +5,15 @@ * BenchmarkCardData projection (server) so the hub can ship pre-shrunk * series over the RSC wire without changing what the chart draws. */ -export function downsample(values: number[], target: number): number[] { - if (values.length <= target) return values; +export function downsample(values: (number | null)[], target: number): number[] { + // Dense series carry nulls for empty Prom buckets. Sparkline-scale + // charts cannot usefully render a gap, so nulls are dropped: within a + // bucket the mean is taken over real samples only, and buckets with + // no sample at all are omitted (same behavior the pre-null code had + // for empty buckets). + if (values.length <= target) { + return values.filter((v): v is number => v != null); + } const bucketSize = values.length / target; const out: number[] = []; for (let i = 0; i < target; i++) { @@ -15,7 +22,9 @@ export function downsample(values: number[], target: number): number[] { let sum = 0; let n = 0; for (let j = start; j < end && j < values.length; j++) { - sum += values[j]; + const v = values[j]; + if (v == null) continue; + sum += v; n++; } if (n > 0) out.push(sum / n); diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts index 5912361e..8575e3ce 100644 --- a/src/lib/rpc-hub-stats.ts +++ b/src/lib/rpc-hub-stats.ts @@ -223,7 +223,11 @@ async function buildChain(spec: Spec): Promise { const leader = rows[0]; const chain = spec.slug.replace(/-rpc$/, ""); - const leaderSeries = bench.extras.series24h?.[leader.slug]; + // Dense series carry nulls for empty Prom buckets; the hub sparkline + // blob stays numbers-only (48-pt cap, gap fidelity irrelevant there). + const leaderSeries = bench.extras.series24h?.[leader.slug]?.filter( + (v): v is number => v != null, + ); // Unresponsive rows are excluded from `rows` by liveRows (they carry // availability="unavailable" and zero latency), so they can't touch // best/fastest — surface count + identity/success for display-only diff --git a/worker/index.ts b/worker/index.ts index 5ce7be12..ddac2b5f 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -67,7 +67,7 @@ const BENCH_CONCURRENCY = Number(process.env.BENCH_CONCURRENCY ?? 3); function ringFromSeries( window: keyof typeof RING_CADENCE, - series: number[], + series: (number | null)[], now: number, ): SeriesRing { const { stepSec } = RING_CADENCE[window]; From 2998ff39f63ea2ab681321bcd756585a56ce2136 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:35:21 +0200 Subject: [PATCH 3/5] api/series: dense timestamp grid anchored at window start; bridge nulls for video renderer --- src/app/api/series/[slug]/route.ts | 19 +++++++---- src/lib/export-video/fetch-series.ts | 49 +++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index f5554a12..47c52114 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -30,7 +30,7 @@ const getSeriesMapCached = unstable_cache( region: string | undefined, kind: string | undefined, venue: string | undefined, - ): Promise | null> => { + ): Promise | null> => { const sig = filterSig({ chain, region, kind, venue }); const stored = await readMaterialized(slug, sig); if (stored) { @@ -49,7 +49,10 @@ const getSeriesMapCached = unstable_cache( const b = await specToBenchmark(spec, { chain, region, kind, venue }); return (range === "7d" ? b.extras.series7d : b.extras.series30d) ?? null; }, - ["series-by-range-v3"], + // v4: dense series with explicit nulls for empty Prom buckets. v3 + // entries hold the old hole-compressed arrays whose length no longer + // matches the dense timestamp grid emitted below. + ["series-by-range-v4"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -151,7 +154,7 @@ export async function GET( // a 100-KB series map is cheap enough that we still need the row // metadata (name, color, logo) — fetch the cached bench for that // separately so its slim ~50 KB payload reuses the existing cache. - let seriesMap: Record | undefined | null; + let seriesMap: Record | undefined | null; let bench; if (rangeParam === "7d" || rangeParam === "30d") { [seriesMap, bench] = await Promise.all([ @@ -179,15 +182,17 @@ export async function GET( } // Timestamps are not persisted with the series — reconstruct from the - // Prom window. We trust whatever length the series came back with - // (Prom may drop empty buckets) so each provider's values stay aligned - // with the timestamp array. + // Prom window. Series are DENSE (one slot per query_range evaluation + // step, null where Prom had no sample), so the grid spans the full + // window: timestamps[0] = now - window, last = now. Values with nulls + // stay index-aligned with this array; consumers see the outage as + // null slots instead of a silently shifted X-axis. const { windowMs, points: targetPoints } = RANGE_CONFIG[rangeParam]; const firstSeries = Object.values(seriesMap).find((arr) => arr.length > 0); const actualPoints = firstSeries?.length ?? targetPoints; - const stepMs = windowMs / Math.max(1, actualPoints); const endMs = Date.now(); const startMs = endMs - windowMs; + const stepMs = windowMs / Math.max(1, actualPoints - 1); const timestamps: number[] = []; for (let i = 0; i < actualPoints; i++) { timestamps.push(Math.round(startMs + i * stepMs)); diff --git a/src/lib/export-video/fetch-series.ts b/src/lib/export-video/fetch-series.ts index 69136104..8d026518 100644 --- a/src/lib/export-video/fetch-series.ts +++ b/src/lib/export-video/fetch-series.ts @@ -18,6 +18,46 @@ export type SeriesFilters = { venue?: string | null; }; +/** What /api/series actually emits: dense values with `null` for empty + * Prom buckets. The external Remotion renderer consumes plain number + * arrays (BenchPayload contract), so nulls are bridged before hand-off. */ +type WireBenchPayload = Omit & { + providers: (Omit & { + values: (number | null)[]; + })[]; +}; + +/** Bridge null buckets for the video renderer: linear interpolation + * between the nearest real neighbors, and leading / trailing nulls + * clamped to the nearest real value. Honest enough for an animated + * race (the race reads trajectory, not per-bucket truth) and keeps the + * renderer's numbers-only contract intact. Returns [] when the series + * has no real sample at all. */ +export function bridgeGaps(values: (number | null)[]): number[] { + const firstIdx = values.findIndex((v) => v != null); + if (firstIdx === -1) return []; + const out = new Array(values.length); + let prevIdx = -1; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v == null) continue; + if (prevIdx === -1) { + // Clamp leading nulls to the first real value. + for (let j = 0; j <= i; j++) out[j] = v; + } else { + const prev = out[prevIdx]; + const span = i - prevIdx; + for (let j = prevIdx + 1; j <= i; j++) { + out[j] = prev + ((v - prev) * (j - prevIdx)) / span; + } + } + prevIdx = i; + } + // Clamp trailing nulls to the last real value. + for (let j = prevIdx + 1; j < values.length; j++) out[j] = out[prevIdx]; + return out; +} + export async function fetchBenchSeries( slug: string, range: RangeId, @@ -36,5 +76,12 @@ export async function fetchBenchSeries( const text = await res.text().catch(() => ""); throw new Error(`/api/series failed (${res.status}): ${text || res.statusText}`); } - return res.json(); + const wire: WireBenchPayload = await res.json(); + return { + ...wire, + providers: wire.providers.map((p) => ({ + ...p, + values: bridgeGaps(p.values), + })), + }; } From 09fad9130aa3740d7666a1d91fc0ad0125eab4e6 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:35:43 +0200 Subject: [PATCH 4/5] spec: bump bench cache keys for dense series shape --- src/lib/spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 048802c0..7f9ea805 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -269,7 +269,11 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // level on VERCEL_ENV=production). Bench SET now differs per env, so // the env is part of the cache key to keep prod and preview entries // from colliding. - ["bench-unfiltered-v26", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v27: series arrays became dense with explicit nulls for empty Prom + // buckets (gap rendering fix). Cached v26 entries hold the old + // hole-compressed arrays whose indices no longer map onto the nominal + // step grid the chart back-computes timestamps from. + ["bench-unfiltered-v27", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -423,7 +427,8 @@ const loadAllBenchmarksCached = unstable_cache( // ungated; the lockstep bump was missed in #1105 and prod kept serving the // gated catalog to /products for 30+ min after the deploy). // v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship). - ["all-benchmarks-v29", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v30: bumped with bench-unfiltered-v27 (dense series with nulls). + ["all-benchmarks-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -501,7 +506,8 @@ const loadBenchmarkFiltered = unstable_cache( // v15: bumped with bench-unfiltered-v25 (prod-only bench gate); env in key. // v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28). // v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule). - ["bench-filters-v17", process.env.VERCEL_ENV === "production" ? "prod" : "all"], + // v18: bumped with bench-unfiltered-v27 (dense series with nulls). + ["bench-filters-v18", process.env.VERCEL_ENV === "production" ? "prod" : "all"], { revalidate: 300, tags: ["benchmarks"] } ); From dab1fb65fa3fcf410c34ed4a06f57513d70d5b40 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 12 Jul 2026 17:37:09 +0200 Subject: [PATCH 5/5] tests: dense bucket grid + video gap bridging --- src/lib/export-video/fetch-series.test.ts | 20 ++++++ src/lib/prometheus.test.ts | 74 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/lib/export-video/fetch-series.test.ts diff --git a/src/lib/export-video/fetch-series.test.ts b/src/lib/export-video/fetch-series.test.ts new file mode 100644 index 00000000..f89a9713 --- /dev/null +++ b/src/lib/export-video/fetch-series.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeGaps } from "./fetch-series"; + +describe("bridgeGaps", () => { + test("interpolates interior nulls linearly", () => { + expect(bridgeGaps([10, null, null, 40])).toEqual([10, 20, 30, 40]); + }); + + test("clamps leading and trailing nulls to nearest real value", () => { + expect(bridgeGaps([null, null, 5, null])).toEqual([5, 5, 5, 5]); + }); + + test("all-null series collapses to empty", () => { + expect(bridgeGaps([null, null])).toEqual([]); + }); + + test("dense numeric input passes through unchanged", () => { + expect(bridgeGaps([1, 2, 3])).toEqual([1, 2, 3]); + }); +}); diff --git a/src/lib/prometheus.test.ts b/src/lib/prometheus.test.ts index 3a1234c9..74ba7ea7 100644 --- a/src/lib/prometheus.test.ts +++ b/src/lib/prometheus.test.ts @@ -36,3 +36,77 @@ describe("extractMetricName", () => { expect(extractMetricName("5xx_responses")).toBeNull(); }); }); + +import { denseSeriesFromMatrix, type PromMatrix } from "./prometheus"; + +describe("denseSeriesFromMatrix", () => { + // 7d window at 84 requested points: step = floor(604800/84) = 7200s, + // grid = 85 slots (start + k*7200, both endpoints inclusive) — the + // exact geometry of the aggregator-head-lag 7d fetch. + const start = 1_760_000_000; + const step = 7200; + const end = start + 84 * step; + const grid = (k: number) => start + k * step; + + function matrixWithHole(): PromMatrix[] { + // Samples on every grid slot EXCEPT indices 30..39 (a ~20h outage + // hole), mirroring what Prom returns when the harness was down. + const values: [number, string][] = []; + for (let k = 0; k <= 84; k++) { + if (k >= 30 && k <= 39) continue; + values.push([grid(k), String(k)]); + } + return [{ metric: {}, values }]; + } + + test("output length equals the dense grid even with a hole", () => { + const out = denseSeriesFromMatrix(matrixWithHole(), start, end, step); + expect(out).not.toBeNull(); + expect(out!.length).toBe(85); + }); + + test("nulls sit exactly in the hole, values elsewhere", () => { + const out = denseSeriesFromMatrix(matrixWithHole(), start, end, step)!; + for (let k = 0; k <= 84; k++) { + if (k >= 30 && k <= 39) expect(out[k]).toBeNull(); + else expect(out[k]).toBe(k); + } + }); + + test("multi-series values are averaged per bucket", () => { + const a: PromMatrix = { metric: { r: "a" }, values: [[grid(0), "10"], [grid(1), "20"]] }; + const b: PromMatrix = { metric: { r: "b" }, values: [[grid(0), "30"]] }; + const out = denseSeriesFromMatrix([a, b], start, end, step)!; + expect(out[0]).toBe(20); + expect(out[1]).toBe(20); + expect(out[2]).toBeNull(); + }); + + test("returns null when nothing maps onto the grid", () => { + expect(denseSeriesFromMatrix([{ metric: {}, values: [] }], start, end, step)).toBeNull(); + }); + + test("samples snap to the nearest grid slot; out-of-window dropped", () => { + const m: PromMatrix[] = [ + { + metric: {}, + values: [ + [grid(3) + step * 0.4, "1"], // snaps to slot 3 + [grid(5) + 0.5, "2"], // fractional-seconds start, snaps to slot 5 + [start - step, "9"], // before window + [end + step, "9"], // after window + ], + }, + ]; + const out = denseSeriesFromMatrix(m, start, end, step)!; + expect(out[3]).toBe(1); + expect(out[5]).toBe(2); + expect(out.length).toBe(85); + }); + + test("rounds to 6 significant digits", () => { + const m: PromMatrix[] = [{ metric: {}, values: [[grid(0), "123.4567891"]] }]; + const out = denseSeriesFromMatrix(m, start, end, step)!; + expect(out[0]).toBe(123.457); + }); +});