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
153 changes: 153 additions & 0 deletions src/app/api/cron/health-check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { NextResponse, type NextRequest } from "next/server";
import { getBenchmarkSlugs } from "@/data/benchmarks";
import { getSpecs } from "@/lib/spec";
import { Prometheus } from "@/lib/prometheus";

export const runtime = "nodejs";
// Always read live state from prom. ISR cache here would defeat the
// alert. Vercel calls this from the cron entry in vercel.json every 5 min.
export const dynamic = "force-dynamic";

/**
* Cron health-check + slack alerter.
*
* Walks every live bench, asks prometheus two questions per provider:
* 1. Was the provider returning data 6 minutes ago? (prom range query)
* 2. Is it returning data now?
*
* Transitions trigger a slack message. Without state storage we use prom
* itself as the source-of-truth: comparing "now" vs "6 min ago" gives us
* the same up/down/recovered edges Alertmanager would compute, with one
* fewer service to run.
*
* Wiring (operator):
* 1. Set `SLACK_WEBHOOK_URL` in vercel env (production scope only).
* 2. Set `CRON_SECRET` in vercel env, used to gate this route.
* 3. The vercel.json cron config posts here every 5 minutes with
* `Authorization: Bearer ${CRON_SECRET}`.
*
* If `SLACK_WEBHOOK_URL` is unset the route runs in dry-mode: it still
* computes the transitions and returns them as JSON, useful for testing
* without spamming the channel.
*/

const LOOKBACK_SECONDS = 360; // 6 minutes. covers the 5-minute cron + slack delivery slack.
const PROVIDER_FRESH_THRESHOLD_SECONDS = 600; // 10 minutes. window during which we still treat the provider as live.

type ProviderState = {
benchSlug: string;
benchTitle: string;
providerSlug: string;
providerName: string;
wasLive: boolean;
isLive: boolean;
};

function isAuthorized(req: NextRequest): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) return true; // dev mode, no auth required
const auth = req.headers.get("authorization") ?? "";
return auth === `Bearer ${secret}`;
}

export async function GET(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}

const promUrl = process.env.PROMETHEUS_URL?.trim();
if (!promUrl) {
return NextResponse.json({ error: "PROMETHEUS_URL not set" }, { status: 500 });
}

const prom = new Prometheus(promUrl);
const slugs = await getBenchmarkSlugs();
const specs = await getSpecs();
const liveSpecs = specs.filter(
(s) => slugs.includes(s.slug) && s.status === "live",
);

const transitions: ProviderState[] = [];

for (const spec of liveSpecs) {
for (const provider of spec.providers) {
const q = provider.queries?.p50;
if (!q) continue;

// For the freshness probe we just need a metric name to ask prom
// "do you have any sample within X seconds for this label set".
// The trick: `count_over_time(metric{...}[Xs])` returns >0 when
// prom received at least one sample in the window, 0 otherwise.
const metricName = extractFirstMetric(q);
const labelSelector = extractLabelSelector(q);
if (!metricName) continue;

const recentQ = `count_over_time(${metricName}${labelSelector}[${PROVIDER_FRESH_THRESHOLD_SECONDS}s])`;
const pastQ = `count_over_time(${metricName}${labelSelector}[${PROVIDER_FRESH_THRESHOLD_SECONDS}s] offset ${LOOKBACK_SECONDS}s)`;

const [now, past] = await Promise.all([
prom.scalar(recentQ).catch(() => null),
prom.scalar(pastQ).catch(() => null),
]);

const isLive = (now ?? 0) > 0;
const wasLive = (past ?? 0) > 0;

if (isLive !== wasLive) {
transitions.push({
benchSlug: spec.slug,
benchTitle: spec.title,
providerSlug: provider.slug,
providerName: provider.name,
wasLive,
isLive,
});
}
}
}

const webhook = process.env.SLACK_WEBHOOK_URL?.trim();
const sent: { provider: string; text: string }[] = [];
if (webhook && transitions.length > 0) {
for (const t of transitions) {
const emoji = t.isLive ? "✅" : "🔴";
const verb = t.isLive ? "back online" : "offline";
const text = `${emoji} *${t.providerName}* ${verb} on \`${t.benchSlug}\`\nbench: <https://openchainbench.com/benchmarks/${t.benchSlug}|${t.benchTitle}>`;
try {
await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
sent.push({ provider: `${t.benchSlug}/${t.providerSlug}`, text });
} catch {
// best effort - don't break the cron on a single slack failure
}
}
}

return NextResponse.json({
checked: liveSpecs.length,
transitions: transitions.length,
sent: sent.length,
dryRun: !webhook,
transitionsList: transitions,
});
}

// Pull the first raw metric identifier out of a promql query. Mirrors the
// helper in lib/prometheus.ts but specialised to also capture the label
// selector that follows so we can reuse it in the freshness probe.
function extractFirstMetric(q: string): string | null {
const m = /\b([a-zA-Z_:][a-zA-Z0-9_:]*)\s*\{/.exec(q);
if (!m) {
const bare = /\b([a-zA-Z_:][a-zA-Z0-9_:]*)\b/.exec(q);
return bare ? bare[1] : null;
}
return m[1];
}

function extractLabelSelector(q: string): string {
const m = /\{[^{}]*\}/.exec(q);
return m ? m[0] : "";
}
2 changes: 1 addition & 1 deletion src/components/benchmark-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@

function summarize(b: Benchmark | undefined): ChainMeta | null {
if (!b) return null;
const live = b.results.filter((r) => r.ms.p50 > 0);
const live = b.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0);
if (live.length === 0) return { providers: 0, metric: b.metric };
const sorted = [...live].sort((a, c) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50
Expand Down Expand Up @@ -174,8 +174,8 @@
// being split between the dimension row and the chart toolbar.
const chartRegions = chartOnlyRegions(benchmark);
const showChartRegionRow = regionOptions.length === 0 && chartRegions.length > 1;
const [chartRegion, setChartRegion] = useState<string>("all");

Check failure on line 177 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
const chartRegionOptions: ChainOption[] = useMemo(

Check failure on line 178 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useMemo" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
() => [
{ value: "all", label: "All" },
...chartRegions.map((r) => ({ value: r, label: REGION_DISPLAY[r] ?? r })),
Expand Down
2 changes: 1 addition & 1 deletion src/components/chain-headings-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { fmtUnit } from "@/lib/format";
* No-op when the bench has no live data yet.
*/
export function ChainHeadingsSummary({ benchmark }: { benchmark: Benchmark }) {
const liveResults = benchmark.results.filter((r) => r.ms.p50 > 0);
const liveResults = benchmark.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0);
if (liveResults.length === 0) return null;

// Render for benches whose providers ARE chains - currently the
Expand Down
138 changes: 82 additions & 56 deletions src/components/ledger-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ type Props = {
export function LedgerTable({ benchmark }: Props) {
const { results, unit, extras } = benchmark;
const secondary = results[0]?.secondary?.label;
const sorted = [...results].sort(
benchmark.higherIsBetter
? (a, b) => b.ms.p50 - a.ms.p50
: (a, b) => a.ms.p50 - b.ms.p50,
);
// Sort by p50 then push unavailable providers to the bottom. Without
// the secondary sort they'd land at rank #1 on lower-is-better benches
// because their placeholder p50 is 0 - which is what made 0slot, then
// cardano, show up as "fastest" in the recent SERP screenshots.
const sorted = [...results].sort((a, b) => {
const aOff = a.availability === "unavailable" ? 1 : 0;
const bOff = b.availability === "unavailable" ? 1 : 0;
if (aOff !== bOff) return aOff - bOff;
return benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50;
});
const colors = buildProviderColors(results);

const allSeries = Object.values(extras.series24h).flat();
Expand Down Expand Up @@ -127,21 +132,22 @@ function Row({
sparkMax: number;
color: string;
}) {
const isOffline = r.availability === "unavailable";
const deltaPct = fieldP50 > 0 ? ((r.ms.p50 - fieldP50) / fieldP50) * 100 : 0;
const deltaSign = deltaPct > 0 ? "+" : deltaPct < 0 ? "−" : "±";
// Inline p50 bar width relative to the field max
const barPct = Math.max(2, (r.ms.p50 / maxP50) * 100);

return (
<tr className="border-b border-rule transition-colors hover:bg-paper-soft/50">
<tr className={`border-b border-rule transition-colors hover:bg-paper-soft/50 ${isOffline ? "opacity-65" : ""}`}>
{/* Color accent. left edge of row */}
<td
className="p-0 align-middle"
style={{ width: 4 }}
>
<span
className="block w-[3px] h-7 rounded-sm"
style={{ background: color }}
style={{ background: isOffline ? "var(--color-ink-faint)" : color }}
aria-hidden
/>
</td>
Expand All @@ -154,77 +160,97 @@ function Row({
{isRegion(r.slug) ? (
<span
className="font-semibold truncate min-w-0"
style={{ color }}
style={{ color: isOffline ? "var(--color-ink-muted)" : color }}
>
{r.name}
</span>
) : (
<Link
href={`/products/${r.slug}`}
className="font-semibold hover:underline underline-offset-2 truncate min-w-0"
style={{ color }}
style={{ color: isOffline ? "var(--color-ink-muted)" : color }}
>
{r.name}
</Link>
)}
{r.tag && (
{r.tag && !isOffline && (
<span className="hidden sm:inline-block truncate max-w-[140px] md:max-w-[220px] font-sans text-[10px] uppercase tracking-[0.14em] text-ink-muted">
{r.tag}
</span>
)}
{r.type && (
{isOffline && (
<span
className="inline-flex items-center gap-1 shrink-0 font-sans text-[10px] uppercase tracking-[0.14em] text-ink-muted"
title="No samples returned this cycle — provider or its upstream is currently unavailable. Values will reappear once data resumes."
>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-[var(--color-warn,#c08a3c)]" aria-hidden />
Currently unavailable
</span>
)}
{r.type && !isOffline && (
<span className="hidden md:inline-flex">
<ProviderTypeBadge type={r.type} />
</span>
)}
</span>
</td>
{/* p50 with inline data bar */}
<td className="py-2.5 px-3 text-right whitespace-nowrap">
<span className="inline-flex items-center gap-2 justify-end">
<span
className="hidden sm:inline-block h-1.5 rounded-sm"
style={{
width: `${barPct * 0.45}px`,
background: `${color}26`, // 15% alpha
borderLeft: `2px solid ${color}`,
}}
aria-hidden
/>
<span className="text-ink whitespace-nowrap">
{fmtUnit(r.ms.p50, unit)}
</span>
</span>
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{fmtUnit(r.ms.p90, unit)}
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{fmtUnit(r.ms.p99, unit)}
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{fmtUnit(r.ms.mean, unit)}
</td>
<td className="py-2.5 px-3 text-right text-ink-muted whitespace-nowrap hidden md:table-cell">
{fieldP50 > 0 ? `${deltaSign}${Math.abs(deltaPct).toFixed(0)}%` : "-"}
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{r.successRate.toFixed(2)}%
</td>
<td className="py-2.5 pl-3 text-right">
<span className="inline-flex items-center justify-end">
<Sparkline
values={series}
color={color}
globalMin={sparkMin}
globalMax={sparkMax}
/>
</span>
</td>
{hasSecondary && (
<td className="py-2.5 pl-3 text-right text-ink-soft">
{r.secondary?.value ?? "-"}
{isOffline ? (
<td
colSpan={hasSecondary ? 8 : 7}
className="py-2.5 px-3 text-right text-ink-faint italic text-[12px]"
>
Awaiting next successful scrape
</td>
) : (
<>
{/* p50 with inline data bar */}
<td className="py-2.5 px-3 text-right whitespace-nowrap">
<span className="inline-flex items-center gap-2 justify-end">
<span
className="hidden sm:inline-block h-1.5 rounded-sm"
style={{
width: `${barPct * 0.45}px`,
background: `${color}26`, // 15% alpha
borderLeft: `2px solid ${color}`,
}}
aria-hidden
/>
<span className="text-ink whitespace-nowrap">
{fmtUnit(r.ms.p50, unit)}
</span>
</span>
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{fmtUnit(r.ms.p90, unit)}
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{fmtUnit(r.ms.p99, unit)}
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{fmtUnit(r.ms.mean, unit)}
</td>
<td className="py-2.5 px-3 text-right text-ink-muted whitespace-nowrap hidden md:table-cell">
{fieldP50 > 0 ? `${deltaSign}${Math.abs(deltaPct).toFixed(0)}%` : "-"}
</td>
<td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell">
{r.successRate.toFixed(2)}%
</td>
<td className="py-2.5 pl-3 text-right">
<span className="inline-flex items-center justify-end">
<Sparkline
values={series}
color={color}
globalMin={sparkMin}
globalMax={sparkMax}
/>
</span>
</td>
{hasSecondary && (
<td className="py-2.5 pl-3 text-right text-ink-soft">
{r.secondary?.value ?? "-"}
</td>
)}
</>
)}
</tr>
);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/bench-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const TEMPLATE_RE = /\{\{\s*([a-z][a-z0-9_]*)(?::([a-z0-9-]+))?\s*\}\}/gi;

export function renderTemplate(text: string, benchmark: Benchmark): string {
if (!text || text.indexOf("{{") === -1) return text;
const liveResults = benchmark.results.filter((r) => r.ms.p50 > 0);
const liveResults = benchmark.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0);
const sorted = [...liveResults].sort((a, b) =>
benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/citation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { fmtUnit } from "@/lib/format";
/** Median value of the benchmark (the field shown in the headline). */
export function fieldValue(b: Benchmark): number | null {
if (b.status !== "live") return null;
const live = b.results.filter((r) => r.ms.p50 > 0);
const live = b.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0);
if (live.length === 0) return null;
const sorted = [...live].sort((a, c) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50
Expand All @@ -21,7 +21,7 @@ export function fieldValue(b: Benchmark): number | null {
/** Who is currently #1 on this benchmark, if any. */
export function leader(b: Benchmark): { name: string; slug: string; value: number } | null {
if (b.status !== "live") return null;
const live = b.results.filter((r) => r.ms.p50 > 0);
const live = b.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0);
if (live.length === 0) return null;
const sorted = [...live].sort((a, c) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50
Expand Down
Loading
Loading