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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions scripts/dry-run-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
);
}
Expand Down
19 changes: 12 additions & 7 deletions src/app/api/series/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const getSeriesMapCached = unstable_cache(
region: string | undefined,
kind: string | undefined,
venue: string | undefined,
): Promise<Record<string, number[]> | null> => {
): Promise<Record<string, (number | null)[]> | null> => {
const sig = filterSig({ chain, region, kind, venue });
const stored = await readMaterialized(slug, sig);
if (stored) {
Expand All @@ -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"] },
);

Expand Down Expand Up @@ -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<string, number[]> | undefined | null;
let seriesMap: Record<string, (number | null)[]> | undefined | null;
let bench;
if (rangeParam === "7d" || rangeParam === "30d") {
[seriesMap, bench] = await Promise.all([
Expand Down Expand Up @@ -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));
Expand Down
7 changes: 6 additions & 1 deletion src/app/benchmarks/[slug]/share-card/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand Down
8 changes: 6 additions & 2 deletions src/components/ledger-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/mini-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]> };
extras: { series24h: Record<string, (number | null)[]> };
};

type Props = {
Expand Down
21 changes: 14 additions & 7 deletions src/components/sparkline.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
Expand Down
32 changes: 25 additions & 7 deletions src/components/time-series-chart/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]);

Expand Down
20 changes: 10 additions & 10 deletions src/components/time-series-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>;
seriesOverride?: Record<string, (number | null)[]>;
/** 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<string, number[]>;
seriesOverride30d?: Record<string, number[]>;
seriesOverride7d?: Record<string, (number | null)[]>;
seriesOverride30d?: Record<string, (number | null)[]>;
metricLabelOverride?: string;
unitOverride?: Benchmark["unit"];
/** Direction override for ranking when a metric panel is active. Bench
Expand Down Expand Up @@ -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<Record<string, number[]> | null>(null);
const [lazySeries30d, setLazySeries30d] = useState<Record<string, number[]> | null>(null);
const [lazySeries7d, setLazySeries7d] = useState<Record<string, (number | null)[]> | null>(null);
const [lazySeries30d, setLazySeries30d] = useState<Record<string, (number | null)[]> | 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
Expand Down Expand Up @@ -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<string, number[]> = {};
const map: Record<string, (number | null)[]> = {};
for (const p of data.providers) map[p.slug] = p.values;
if (range === "7d") setLazySeries7d(map);
else setLazySeries30d(map);
Expand Down Expand Up @@ -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<string, number[]> | undefined => {
const pickPanel = (): Record<string, (number | null)[]> | 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;
Expand All @@ -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] ?? [];
}
Expand Down
12 changes: 7 additions & 5 deletions src/components/time-series-chart/scales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -93,7 +94,7 @@ export function pickSeries(
slug: string,
range: Range,
region: string
): number[] {
): (number | null)[] {
const allRegion = isAll(region);

if (!allRegion) {
Expand Down Expand Up @@ -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;
}

/**
Expand Down
9 changes: 5 additions & 4 deletions src/components/time-series-chart/series.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/lib/citation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>): number[] | null {
function firstSeries(
s: Record<string, (number | null)[]>,
): (number | null)[] | null {
for (const k of Object.keys(s)) {
const v = s[k];
if (v && v.length > 0) return v;
Expand Down
15 changes: 12 additions & 3 deletions src/lib/downsample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -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);
Expand Down
Loading
Loading