📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Build Minutes climb on monorepos when Turborepo cache is bypassed or every project rebuilds on every commit.
|
||||
// Threshold: Build Minutes line > 15% of total bill OR scanner emits any turbo-force-bypass finding (even at lower share).
|
||||
// Account-scoped because the lever is project-settings (Ignored Build Step, Elastic Build Machines), not code.
|
||||
export const metadata = {
|
||||
id: 'build_minutes_fanout',
|
||||
threshold: 'Build Minutes share > 0.15 OR turbo-force-bypass finding present',
|
||||
billingDimension: 'build',
|
||||
scope: 'account',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Build Minutes line dominates the bill or Turborepo cache is bypassed. On monorepos, unchanged work should be skipped through Vercel skip-unaffected behavior, a verified Ignored Build Step, and a complete Turbo cache contract.',
|
||||
};
|
||||
|
||||
const BUILD_RE = /^Build (CPU )?Minutes$/i;
|
||||
const SCANNER_PATTERN = 'turbo-force-bypass';
|
||||
const SHARE_FLOOR = 0.15;
|
||||
|
||||
export function gate(signals) {
|
||||
const services = signals?.usage?.services;
|
||||
const total = Array.isArray(services)
|
||||
? services.reduce((acc, s) => acc + Number(s.billedCost ?? s.cost ?? 0), 0)
|
||||
: 0;
|
||||
const buildBilled = Array.isArray(services)
|
||||
? services
|
||||
.filter((s) => BUILD_RE.test(String(s?.name ?? '')))
|
||||
.reduce((acc, s) => acc + Number(s.billedCost ?? s.cost ?? 0), 0)
|
||||
: 0;
|
||||
|
||||
const buildShare = total > 0 ? buildBilled / total : 0;
|
||||
|
||||
const findings = (signals?.codebase?.findings ?? []).filter((f) => f.pattern === SCANNER_PATTERN);
|
||||
|
||||
if (buildShare <= SHARE_FLOOR && findings.length === 0) return [];
|
||||
|
||||
const subtypes = unique(findings.map((f) => f.subtype).filter(Boolean));
|
||||
const sampleFiles = unique(findings.map((f) => f.file).filter(Boolean)).slice(0, 4);
|
||||
|
||||
const reason = findings.length > 0
|
||||
? (buildShare > SHARE_FLOOR
|
||||
? 'Build Minutes share is high and Turborepo cache bypass detected in repo'
|
||||
: 'Turborepo cache bypass detected in repo')
|
||||
: 'Build Minutes line exceeds 15% of total billed cost';
|
||||
|
||||
return [{
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: sampleFiles,
|
||||
priority: findings.length > 0 ? 65 : 50,
|
||||
confidence: findings.length > 0 ? 0.86 : 0.74,
|
||||
o11ySignal: `build_minutes_share=${(buildShare * 100).toFixed(0)}% scanner_findings=${findings.length}`,
|
||||
reason,
|
||||
question: findings.length > 0
|
||||
? `Turborepo cache bypass detected (${subtypes.join(', ')}). Which build pipeline forces a rebuild on every commit, and can Ignored Build Step + cache re-enable cut the project fan-out?`
|
||||
: 'Build Minutes exceed 15% of the bill. Is Ignored Build Step configured? Is Turborepo cache active across builds? Would Elastic Build Machines reduce duration on hot builds?',
|
||||
evidence: {
|
||||
metric: 'usage.services',
|
||||
buildBilled,
|
||||
totalBilled: total,
|
||||
buildShare,
|
||||
scannerFindings: findings.length,
|
||||
scannerSubtypes: subtypes,
|
||||
sampleFiles,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Signal: `function_start_type` dimension on `vercel.function_invocation.count` (cold|hot|prewarmed).
|
||||
// Threshold WHY: 40%+ cold is fixable via Fluid keep-warm; 30% is the noise floor for serverless without keep-warm.
|
||||
// total>=1000/14d (~3/hr) keeps Poisson CI on cold rate at ~±5% near the 40% threshold.
|
||||
export const metadata = {
|
||||
id: 'cold_start',
|
||||
threshold: 'coldPct > 0.4 AND total >= 1000',
|
||||
billingDimension: 'function-duration',
|
||||
scope: 'route',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Routes where > 40% of invocations are cold-start, at meaningful traffic (>=1,000 total invocations in window). Cold starts add 200-800ms per request and break the perceived latency budget on cache-miss paths. The 40% threshold is where cold-rate becomes a real signal vs Poisson noise on serverless. Sourced from vercel.function_invocation.count grouped by function_start_type.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const cs = extractColdStarts(signals);
|
||||
return cs
|
||||
.filter((r) => r.coldPct > 0.4 && r.total >= 1000)
|
||||
.map((r) => ({
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route: r.route,
|
||||
files: [],
|
||||
priority: Math.round(r.total * r.coldPct),
|
||||
confidence: 0.92,
|
||||
o11ySignal: `cold=${(r.coldPct * 100).toFixed(0)}%,inv=${r.total}`,
|
||||
reason: 'high cold-start rate on hot route',
|
||||
question: `What initialization or bundle overhead makes ${r.route} cold-start ${(r.coldPct * 100).toFixed(0)}% of ${r.total} invocations?`,
|
||||
evidence: { metric: 'fnStartTypeByRoute', route: r.route, coldPct: r.coldPct, total: r.total, coldCount: r.coldCount ?? null },
|
||||
}));
|
||||
}
|
||||
|
||||
function extractColdStarts(signals) {
|
||||
const live = signals.metrics?.fnStartTypeByRoute;
|
||||
if (Array.isArray(live?.rows) && live.rows.some((r) => 'coldCount' in r || 'coldPct' in r)) {
|
||||
return live.rows
|
||||
.filter((r) => r.route)
|
||||
.map((r) => ({
|
||||
route: r.route,
|
||||
total: r.total ?? 0,
|
||||
coldCount: r.coldCount ?? 0,
|
||||
coldPct: r.coldPct ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// Legacy fixture: pre-derived coldStartByRoute rows.
|
||||
const direct = signals.metrics?.coldStartByRoute;
|
||||
if (Array.isArray(direct?.rows)) {
|
||||
return direct.rows
|
||||
.filter((r) => r.route)
|
||||
.map((r) => ({ route: r.route, coldPct: r.coldPct ?? 0, total: r.total ?? 0 }));
|
||||
}
|
||||
|
||||
// Older legacy fixture: series + summary shape.
|
||||
const legacy = signals.metrics?.coldStarts;
|
||||
if (Array.isArray(legacy?.series)) {
|
||||
return legacy.series
|
||||
.map((s) => {
|
||||
const total = s.summary?.count ?? 0;
|
||||
const coldCount = s.summary?.coldCount ?? s.summary?.sum ?? 0;
|
||||
return { route: s.groupValues?.route, total, coldPct: total > 0 ? coldCount / total : 0 };
|
||||
})
|
||||
.filter((r) => r.route);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const VALID_SCOPES = new Set(['route', 'file', 'account']);
|
||||
|
||||
export class CandidateContractError extends Error {
|
||||
constructor(errors) {
|
||||
super(`gate candidate contract failed:\n${errors.map((e) => `- ${e}`).join('\n')}`);
|
||||
this.name = 'CandidateContractError';
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCandidates(candidates, ctx = {}) {
|
||||
if (!Array.isArray(candidates)) {
|
||||
throw new CandidateContractError([`${ctx.source ?? 'gate'}: expected candidate array`]);
|
||||
}
|
||||
const errors = [];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
errors.push(...validateCandidate(candidates[i], { ...ctx, index: i }).errors);
|
||||
}
|
||||
if (errors.length > 0) throw new CandidateContractError(errors);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function validateCandidate(candidate, ctx = {}) {
|
||||
const label = candidateLabel(candidate, ctx);
|
||||
const errors = [];
|
||||
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
||||
return { ok: false, errors: [`${label}: candidate must be an object`] };
|
||||
}
|
||||
|
||||
if (!nonEmptyString(candidate.kind)) errors.push(`${label}: kind must be a non-empty string`);
|
||||
if (!VALID_SCOPES.has(candidate.scope)) {
|
||||
errors.push(`${label}: scope must be one of route, file, account`);
|
||||
}
|
||||
if (!Number.isFinite(candidate.priority)) errors.push(`${label}: priority must be a finite number`);
|
||||
if (!Number.isFinite(candidate.confidence)) errors.push(`${label}: confidence must be a finite number`);
|
||||
if (Array.isArray(candidate.files)) {
|
||||
if (!candidate.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
errors.push(`${label}: files must contain only non-empty strings`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${label}: files must be an array`);
|
||||
}
|
||||
if (!nonEmptyString(candidate.reason)) errors.push(`${label}: reason must be a non-empty string`);
|
||||
if (!nonEmptyString(candidate.question)) errors.push(`${label}: question must be a non-empty string`);
|
||||
|
||||
if (candidate.scope === 'route') {
|
||||
const hasRoute = nonEmptyString(candidate.route);
|
||||
const hasHostname = nonEmptyString(candidate.hostname);
|
||||
if (!hasRoute && !hasHostname) {
|
||||
errors.push(`${label}: route-scoped candidates must set route or hostname`);
|
||||
}
|
||||
}
|
||||
if (candidate.scope === 'file') {
|
||||
if (candidate.route != null || candidate.hostname != null) {
|
||||
errors.push(`${label}: file-scoped candidates must not set route or hostname`);
|
||||
}
|
||||
if (!Array.isArray(candidate.files) || candidate.files.length === 0) {
|
||||
errors.push(`${label}: file-scoped candidates must include at least one file`);
|
||||
}
|
||||
}
|
||||
if (candidate.scope === 'account') {
|
||||
if (candidate.route != null || candidate.hostname != null) {
|
||||
errors.push(`${label}: account-scoped candidates must not set route or hostname`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function candidateLabel(candidate, ctx) {
|
||||
const source = ctx.source ?? 'gate';
|
||||
const index = ctx.index == null ? '?' : ctx.index;
|
||||
const kind = candidate?.kind ?? '?';
|
||||
return `${source}[${index}] ${kind}`;
|
||||
}
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Thresholds are Google's "Poor" band (https://web.dev/articles/vitals): LCP p75 > 2500ms, INP > 200ms, CLS > 0.1.
|
||||
// When Speed Insights isn't wired up the metrics come back empty and the gate is a no-op.
|
||||
import { withRouteShapeWarnings } from '../route-normalize.mjs';
|
||||
|
||||
export const metadata = {
|
||||
id: 'cwv_poor',
|
||||
threshold: 'LCP p75>2500 OR INP p75>200 OR CLS p75>0.1, AND speed_insights count > 50',
|
||||
billingDimension: 'speed-insights',
|
||||
scope: 'route',
|
||||
sourceCitation: 'https://web.dev/articles/vitals',
|
||||
description:
|
||||
'Routes where Core Web Vitals fall into Google\'s "Poor" band on real-user traffic. LCP > 2500ms, INP > 200ms, or CLS > 0.1 each hurt SEO and conversion. Surfaces one candidate per (route, metric) pair to keep recommendations focused.',
|
||||
};
|
||||
|
||||
// Below this floor p75 is too noisy to act on.
|
||||
const MIN_PER_ROUTE_SAMPLES = 50;
|
||||
|
||||
export function gate(signals) {
|
||||
const totalSamples = sumRows(signals.metrics?.cwvCount?.rows);
|
||||
if (totalSamples === 0) return [];
|
||||
|
||||
const countByRoute = byRoute(signals.metrics?.cwvCountByRoute?.rows);
|
||||
const lcpBy = byRoute(signals.metrics?.cwvLcpByRoute?.rows);
|
||||
const inpBy = byRoute(signals.metrics?.cwvInpByRoute?.rows);
|
||||
const clsBy = byRoute(signals.metrics?.cwvClsByRoute?.rows);
|
||||
|
||||
const routes = new Set([...lcpBy.keys(), ...inpBy.keys(), ...clsBy.keys()]);
|
||||
const out = [];
|
||||
for (const route of routes) {
|
||||
const routeSamples = countByRoute.get(route) ?? 0;
|
||||
if (routeSamples < MIN_PER_ROUTE_SAMPLES) continue;
|
||||
const lcp = lcpBy.get(route);
|
||||
const inp = inpBy.get(route);
|
||||
const cls = clsBy.get(route);
|
||||
const issues = [];
|
||||
if (lcp != null && lcp > 2500) issues.push({ metric: 'LCP', value: Math.round(lcp), threshold: 2500, unit: 'ms' });
|
||||
if (inp != null && inp > 200) issues.push({ metric: 'INP', value: Math.round(inp), threshold: 200, unit: 'ms' });
|
||||
if (cls != null && cls > 0.1) issues.push({ metric: 'CLS', value: round2(cls), threshold: 0.1, unit: '' });
|
||||
if (issues.length === 0) continue;
|
||||
|
||||
const summary = issues.map((i) => `${i.metric}=${i.value}${i.unit}`).join(',');
|
||||
out.push(withRouteShapeWarnings({
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route,
|
||||
files: [],
|
||||
priority: issues.reduce((s, i) => s + ratioOverThreshold(i), 0) * 10,
|
||||
confidence: 0.82,
|
||||
o11ySignal: summary,
|
||||
reason: 'real-user Core Web Vitals in poor band',
|
||||
question: `On ${route}, ${summary}. Which client-side work (bundle weight, blocking scripts, layout shifts, hydration) is responsible, and which change would land first?`,
|
||||
evidence: {
|
||||
metric: 'cwv',
|
||||
route,
|
||||
lcpMs: lcp != null ? Math.round(lcp) : null,
|
||||
inpMs: inp != null ? Math.round(inp) : null,
|
||||
cls: cls != null ? round2(cls) : null,
|
||||
issues,
|
||||
totalSpeedInsightsSamples: totalSamples,
|
||||
routeSpeedInsightsSamples: routeSamples,
|
||||
},
|
||||
}, signals));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function byRoute(rows) {
|
||||
const m = new Map();
|
||||
for (const r of rows ?? []) {
|
||||
if (!r.route || r.value == null) continue;
|
||||
m.set(r.route, r.value);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function sumRows(rows) {
|
||||
if (!Array.isArray(rows)) return 0;
|
||||
return rows.reduce((s, r) => s + (r.value ?? 0), 0);
|
||||
}
|
||||
|
||||
function round2(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function ratioOverThreshold(i) {
|
||||
return i.value / (i.threshold || 1);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Volume floor pairs p75 with call_count so a single 5s cron/day doesn't fire the gate.
|
||||
const MIN_CALL_COUNT = 500;
|
||||
|
||||
export const metadata = {
|
||||
id: 'external_api_slow',
|
||||
threshold: `p75Ms > 2000 AND callCount >= ${MIN_CALL_COUNT}`,
|
||||
billingDimension: 'function-duration',
|
||||
scope: 'route',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'External API hostnames with p75 latency above 2 seconds AND at least 500 calls in the window. External API latency is a primary driver of function duration cost when the upstream is on a hot path; a single slow stale call isn\'t worth recommending against.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const apis = extractExternalApis(signals);
|
||||
const calls = extractCallCounts(signals);
|
||||
return apis
|
||||
.map((a) => ({ ...a, callCount: calls.get(a.hostname) ?? 0 }))
|
||||
.filter((a) => a.p75Ms > 2000 && a.callCount >= MIN_CALL_COUNT)
|
||||
.map((a) => ({
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route: null,
|
||||
files: [],
|
||||
hostname: a.hostname,
|
||||
// Weight by latency × call volume so 100k-call/2.1s outranks 1k-call/8s.
|
||||
priority: Math.round((a.p75Ms * a.callCount) / 1000),
|
||||
confidence: 0.88,
|
||||
o11ySignal: `host=${a.hostname},p75=${a.p75Ms}ms,calls=${a.callCount}`,
|
||||
reason: 'slow external dependency on hot path',
|
||||
question: `Which routes call ${a.hostname} (p75=${a.p75Ms}ms across ${a.callCount} calls), and can the call be parallelized, cached, or moved off the critical path?`,
|
||||
evidence: { metric: 'externalApiP75', hostname: a.hostname, p75Ms: a.p75Ms, callCount: a.callCount },
|
||||
}));
|
||||
}
|
||||
|
||||
function extractExternalApis(signals) {
|
||||
const m = signals.metrics?.externalApiP75;
|
||||
if (!m?.ok && !Array.isArray(m?.rows)) return [];
|
||||
return (m?.rows ?? [])
|
||||
.map((r) => ({
|
||||
hostname: r.origin_hostname,
|
||||
p75Ms: Math.round(r.value ?? 0),
|
||||
}))
|
||||
.filter((a) => a.hostname);
|
||||
}
|
||||
|
||||
function extractCallCounts(signals) {
|
||||
const m = signals.metrics?.externalApiCount;
|
||||
const out = new Map();
|
||||
if (!m) return out;
|
||||
for (const r of m.rows ?? []) {
|
||||
if (r?.origin_hostname) out.set(r.origin_hostname, r.value ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { canonicalizeRoute } from '../route-normalize.mjs';
|
||||
|
||||
export const FLAGS_ENDPOINT = '/.well-known/vercel/flags';
|
||||
export const VERCEL_FLAGS_PACKAGES = [
|
||||
'@vercel/flags',
|
||||
'@vercel/flags/next',
|
||||
'@vercel/flags/sveltekit',
|
||||
'@vercel/flags/nuxt',
|
||||
];
|
||||
export const WORKFLOW_ENDPOINT_PREFIXES = [
|
||||
'/.well-known/workflow',
|
||||
'/api/.well-known/workflow',
|
||||
];
|
||||
|
||||
export function applyHardGates(candidates, signals = {}) {
|
||||
const allowed = [];
|
||||
const gated = [];
|
||||
for (const candidate of candidates) {
|
||||
if (isFlagsEndpointCandidate(candidate)) {
|
||||
gated.push({
|
||||
...candidate,
|
||||
gatedReason: flagsEndpointReason(signals),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isWorkflowRuntimeEndpointCandidate(candidate)) {
|
||||
gated.push({
|
||||
...candidate,
|
||||
gatedReason: workflowEndpointReason(signals),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
allowed.push(candidate);
|
||||
}
|
||||
return { allowed, gated };
|
||||
}
|
||||
|
||||
export function isFlagsEndpointCandidate(candidate) {
|
||||
if (!candidate || candidate.scope === 'account') return false;
|
||||
const route = normalizeRoute(candidate.route);
|
||||
return route === FLAGS_ENDPOINT;
|
||||
}
|
||||
|
||||
export function isWorkflowRuntimeEndpointCandidate(candidate) {
|
||||
if (!candidate || candidate.scope === 'account') return false;
|
||||
const route = normalizeRoute(candidate.route);
|
||||
if (!route) return false;
|
||||
return WORKFLOW_ENDPOINT_PREFIXES.some((prefix) => (
|
||||
route === prefix || route.startsWith(`${prefix}/`)
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeRoute(route) {
|
||||
if (typeof route !== 'string') return null;
|
||||
const normalized = canonicalizeRoute(route).replace(/\/+$/, '');
|
||||
return normalized === '' ? '/' : normalized;
|
||||
}
|
||||
|
||||
function flagsEndpointReason(signals) {
|
||||
const packages = signals.stack?.vercelFlagsPackages;
|
||||
if (Array.isArray(packages) && packages.length > 0) {
|
||||
return `hardGated: ${FLAGS_ENDPOINT} is the Vercel Flags endpoint (${packages.join(', ')} detected), not an optimization target`;
|
||||
}
|
||||
return `hardGated: ${FLAGS_ENDPOINT} is the Vercel Flags endpoint, not an optimization target`;
|
||||
}
|
||||
|
||||
function workflowEndpointReason(signals) {
|
||||
const packages = signals.stack?.workflowPackages;
|
||||
if (Array.isArray(packages) && packages.length > 0) {
|
||||
return `hardGated: Vercel Workflow runtime endpoint (${packages.join(', ')} detected); long-running step/flow requests are expected orchestration, not an app-route optimization target`;
|
||||
}
|
||||
return 'hardGated: Vercel Workflow runtime endpoint; long-running step/flow requests are expected orchestration, not an app-route optimization target';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as uncachedRoute from './uncached-route.mjs';
|
||||
import * as slowRoute from './slow-route.mjs';
|
||||
import * as routeErrors from './route-errors.mjs';
|
||||
import * as coldStart from './cold-start.mjs';
|
||||
import * as isrOverrevalidation from './isr-overrevalidation.mjs';
|
||||
import * as cwvPoor from './cwv-poor.mjs';
|
||||
import * as platformFluidCompute from './platform-fluid-compute.mjs';
|
||||
import * as platformBotProtection from './platform-bot-protection.mjs';
|
||||
import * as middlewareHeavy from './middleware-heavy.mjs';
|
||||
import * as externalApiSlow from './external-api-slow.mjs';
|
||||
import * as scannerDriven from './scanner-driven.mjs';
|
||||
import * as observabilityEventsAttribution from './observability-events-attribution.mjs';
|
||||
import * as usageSpikeTriage from './usage-spike-triage.mjs';
|
||||
import * as buildMinutesFanout from './build-minutes-fanout.mjs';
|
||||
import * as regionMisconfig from './region-misconfig.mjs';
|
||||
|
||||
// Intentionally NOT registered:
|
||||
// - `oversized_memory`: Fluid Compute floor is 2GB; per-route memory right-sizing isn't a customer lever.
|
||||
// - `deploy_regression`: overlaps Vercel Agent Investigations; `vercel inspect` 404s across teams. slow_route deep-dive already carries per-deployment p95 trend.
|
||||
export const gates = [
|
||||
uncachedRoute,
|
||||
slowRoute,
|
||||
routeErrors,
|
||||
coldStart,
|
||||
isrOverrevalidation,
|
||||
cwvPoor,
|
||||
externalApiSlow,
|
||||
scannerDriven,
|
||||
// Account-scoped last so platform-scoped sort doesn't dilute code-scoped priority ordering during budget application.
|
||||
platformFluidCompute,
|
||||
platformBotProtection,
|
||||
middlewareHeavy,
|
||||
observabilityEventsAttribution,
|
||||
usageSpikeTriage,
|
||||
buildMinutesFanout,
|
||||
regionMisconfig,
|
||||
];
|
||||
|
||||
// Overridable via `--max-candidates N` or `VERCEL_OPTIMIZE_MAX_CANDIDATES` (accepts `all`).
|
||||
// `MAX_CODE_CANDIDATES` is a back-compat alias for tests importing the old name.
|
||||
export const DEFAULT_MAX_CODE_CANDIDATES = 6;
|
||||
export const MAX_CODE_CANDIDATES = DEFAULT_MAX_CODE_CANDIDATES;
|
||||
|
||||
// Bump on any threshold change so report + iteration baselines can detect gate-logic drift.
|
||||
export const GATE_VERSION = '1.8.0';
|
||||
@@ -0,0 +1,62 @@
|
||||
// ISR writes re-execute the page render. A w/r ratio above 0.5 means writes are
|
||||
// happening at least once for every two reads — high enough for the default
|
||||
// audit to spend investigation budget. writes>100 avoids flapping on quiet routes.
|
||||
export const metadata = {
|
||||
id: 'isr_overrevalidation',
|
||||
threshold: 'writes/reads > 0.5 AND writes > 100',
|
||||
billingDimension: 'isr',
|
||||
scope: 'route',
|
||||
sourceCitation: 'https://vercel.com/docs/incremental-static-regeneration',
|
||||
description:
|
||||
'ISR routes with > 1 write per 2 reads. The revalidate interval is too aggressive relative to read traffic — many reads pay to regenerate. Investigate whether the page can tolerate a longer revalidate window or on-demand revalidation via revalidateTag.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const rows = extractRows(signals);
|
||||
return rows
|
||||
.filter((r) => r.writes > 100 && r.reads > 0 && r.writes / r.reads > 0.5)
|
||||
.map((r) => {
|
||||
const ratio = r.writes / r.reads;
|
||||
return {
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route: r.route,
|
||||
files: [],
|
||||
priority: Math.round(r.writes),
|
||||
confidence: 0.88,
|
||||
o11ySignal: `writes=${r.writes},reads=${r.reads},w/r=${ratio.toFixed(2)}`,
|
||||
reason: 'ISR revalidating faster than read traffic justifies',
|
||||
question: `On ${r.route}, ${r.writes} ISR writes against ${r.reads} reads (${(ratio * 100).toFixed(0)} writes per 100 reads) — what is the current revalidate interval and can it be lengthened or switched to on-demand?`,
|
||||
evidence: {
|
||||
metric: 'isrWritesByRoute',
|
||||
route: r.route,
|
||||
writes: r.writes,
|
||||
reads: r.reads,
|
||||
ratio,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractRows(signals) {
|
||||
const writes = signals.metrics?.isrWritesByRoute?.rows ?? [];
|
||||
const reads = signals.metrics?.isrReadsByRoute?.rows ?? [];
|
||||
|
||||
const writeByRoute = new Map();
|
||||
for (const r of writes) {
|
||||
if (!r.route) continue;
|
||||
writeByRoute.set(r.route, (writeByRoute.get(r.route) ?? 0) + (r.value ?? 0));
|
||||
}
|
||||
const readByRoute = new Map();
|
||||
for (const r of reads) {
|
||||
if (!r.route) continue;
|
||||
readByRoute.set(r.route, (readByRoute.get(r.route) ?? 0) + (r.value ?? 0));
|
||||
}
|
||||
|
||||
const routes = new Set([...writeByRoute.keys(), ...readByRoute.keys()]);
|
||||
return [...routes].map((route) => ({
|
||||
route,
|
||||
writes: writeByRoute.get(route) ?? 0,
|
||||
reads: readByRoute.get(route) ?? 0,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Middleware runs in front of every matching request and is billed as edge invocations.
|
||||
// If >50% of traffic hits middleware, the matcher is probably broader than necessary.
|
||||
export const metadata = {
|
||||
id: 'middleware_heavy',
|
||||
threshold: 'middlewareInv/totalInv > 0.5 AND middlewareInv > 1000',
|
||||
billingDimension: 'edge-requests',
|
||||
scope: 'account',
|
||||
sourceCitation: 'https://nextjs.org/docs/app/building-your-application/routing/middleware',
|
||||
description:
|
||||
'Middleware invocations cover > 50% of total requests at non-trivial volume. The matcher is probably broader than necessary; narrow it to the paths that actually need auth/rewrites/headers.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const middlewareInv = sumRows(signals.metrics?.middlewareCount?.rows);
|
||||
if (middlewareInv < 1000) return [];
|
||||
|
||||
const totalInv = sumRows(signals.metrics?.requestsByRouteCache?.rows);
|
||||
if (totalInv === 0) return [];
|
||||
|
||||
const ratio = middlewareInv / totalInv;
|
||||
if (ratio <= 0.5) return [];
|
||||
|
||||
const top = [...(signals.metrics?.middlewareCount?.rows ?? [])]
|
||||
.filter((r) => r.request_path)
|
||||
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0))
|
||||
.slice(0, 5)
|
||||
.map((r) => ({ request_path: r.request_path, count: r.value ?? 0 }));
|
||||
|
||||
return [{
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: [],
|
||||
priority: Math.round(middlewareInv / 1000),
|
||||
confidence: 0.84,
|
||||
o11ySignal: `middleware_inv=${middlewareInv},total_req=${totalInv},ratio=${(ratio * 100).toFixed(0)}%`,
|
||||
reason: 'middleware ran on more than half of all requests',
|
||||
question: `Middleware invocations (${middlewareInv}) are ${(ratio * 100).toFixed(0)}% of all requests (${totalInv}). Which paths in middleware.ts require interception, and can the matcher be narrowed to exclude static assets, images, and routes that do not need rewriting?`,
|
||||
evidence: {
|
||||
metric: 'middlewareCount',
|
||||
middlewareInv,
|
||||
totalInv,
|
||||
ratio,
|
||||
topPaths: top,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
function sumRows(rows) {
|
||||
if (!Array.isArray(rows)) return 0;
|
||||
return rows.reduce((s, r) => s + (r.value ?? 0), 0);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Observability Events is the metered SKU under Observability Plus.
|
||||
// Threshold at >20% surfaces material spend; >30% is the critical band.
|
||||
// Drivers correlate with low cache hit rate, high middleware invocation, and high custom-span cardinality.
|
||||
export const metadata = {
|
||||
id: 'observability_events_attribution',
|
||||
threshold: 'observabilityEventsShare > 0.20 (critical at > 0.30)',
|
||||
billingDimension: 'observability-events',
|
||||
scope: 'account',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Observability Events line item exceeds 20% of total billed cost. High share usually traces to low cache hit rate, middleware-heavy traffic, or unconstrained custom-span cardinality. No sampling lever exists for Observability Plus; reduce upstream invocations instead.',
|
||||
};
|
||||
|
||||
const EVENTS_RE = /^Observability Events$/i;
|
||||
|
||||
export function gate(signals) {
|
||||
const services = signals?.usage?.services;
|
||||
if (!Array.isArray(services) || services.length === 0) return [];
|
||||
|
||||
const total = sumBilled(services);
|
||||
if (total <= 0) return [];
|
||||
|
||||
const eventsBilled = services
|
||||
.filter((s) => EVENTS_RE.test(String(s?.name ?? '')))
|
||||
.reduce((acc, s) => acc + Number(s.billedCost ?? s.cost ?? 0), 0);
|
||||
if (eventsBilled <= 0) return [];
|
||||
|
||||
const share = eventsBilled / total;
|
||||
if (share <= 0.20) return [];
|
||||
|
||||
const critical = share > 0.30;
|
||||
|
||||
return [{
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: [],
|
||||
priority: critical ? 70 : 55,
|
||||
confidence: 0.82,
|
||||
o11ySignal: `observability_events_share=${(share * 100).toFixed(0)}%`,
|
||||
reason: critical
|
||||
? 'observability events exceed 30% of total billed cost'
|
||||
: 'observability events exceed 20% of total billed cost',
|
||||
question: `Observability Events are ${(share * 100).toFixed(0)}% of the bill. Which routes drive event volume — low-cache-hit traffic, broad middleware invocation, or high custom-span cardinality — and can event volume be reduced upstream of the meter?`,
|
||||
evidence: {
|
||||
metric: 'usage.services',
|
||||
eventsBilled,
|
||||
totalBilled: total,
|
||||
observabilityEventsShare: share,
|
||||
critical,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
function sumBilled(services) {
|
||||
return services.reduce((acc, s) => acc + Number(s.billedCost ?? s.cost ?? 0), 0);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// Recommend BotID only when there's EVIDENCE of bot traffic or scale large enough that the rec is defensible.
|
||||
// Without an evidence gate the rec fires on quiet hobby sites and erodes trust.
|
||||
const MIN_BOT_PCT = 0.05;
|
||||
const MIN_EDGE_COST = 25; // halved for 14d window
|
||||
const MIN_TOTAL_REQUESTS = 14_000; // ~14k/14d matches the prior 30k/30d rate
|
||||
const MIN_TOTAL_FDT_BYTES = 1_000_000;
|
||||
|
||||
export const metadata = {
|
||||
id: 'platform_bot_protection',
|
||||
threshold: 'botIdEnabled=false AND (botPct >= 0.05 OR edge_cost >= $25/window OR requests >= 14k/14d)',
|
||||
billingDimension: 'edge-requests',
|
||||
scope: 'account',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'When BotID is disabled AND there is evidence (observed bot bandwidth share, edge cost, or substantial request volume) that bot traffic is non-trivial. Bot traffic inflates edge request counts without delivering user value; staged bot protection can reduce waste on bot-heavy projects. Skipped on quiet projects with no bot evidence — the recommendation would be noise.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
// BotID surfaces under several legacy fields; check all.
|
||||
const botEnabled =
|
||||
signals.project?.security?.botIdEnabled === true
|
||||
|| signals.project?.security?.botProtection === true
|
||||
|| signals.project?.botProtection?.enabled === true
|
||||
|| signals.project?.delegatedProtection?.bot === true;
|
||||
if (botEnabled) return [];
|
||||
|
||||
// Project config failed — we can't tell if BotID is on, so stay silent.
|
||||
if (signals.project?.error) return [];
|
||||
|
||||
const totalRequests = totalRequestsFromSignals(signals);
|
||||
const botShare = computeBotShare(signals);
|
||||
const edgeService = (signals.usage?.services ?? []).find(
|
||||
(s) => /edge.request/i.test(s.name ?? '')
|
||||
);
|
||||
const edgeCost = edgeService?.billedCost ?? null;
|
||||
|
||||
// Require observable bot share, edge cost, OR substantial traffic — otherwise rec is just config nagging.
|
||||
const hasObservedBots = botShare?.botPct != null && botShare.botPct >= MIN_BOT_PCT;
|
||||
const hasMaterialEdgeCost = edgeCost != null && edgeCost >= MIN_EDGE_COST;
|
||||
const hasSubstantialTraffic = totalRequests >= MIN_TOTAL_REQUESTS;
|
||||
if (!hasObservedBots && !hasMaterialEdgeCost && !hasSubstantialTraffic) return [];
|
||||
|
||||
const challengeRule = signals.project?.security?.managedRules?.bot_filter;
|
||||
const ruleNote = challengeRule?.active
|
||||
? `firewall bot_filter rule active (action=${challengeRule.action ?? '?'})`
|
||||
: 'no firewall bot_filter rule';
|
||||
|
||||
// Kicker on high observed bot share — harder evidence than config alone.
|
||||
let priority = edgeCost != null ? Math.max(20, Math.round(edgeCost)) : 30;
|
||||
if (botShare?.botPct != null && botShare.botPct > 0.2) priority += 20;
|
||||
|
||||
// Confidence bumps when we can SEE bot traffic, not just infer from config.
|
||||
let confidence = edgeCost != null ? 0.85 : 0.6;
|
||||
if (botShare?.botPct != null && botShare.botPct > 0.2) confidence = Math.min(0.95, confidence + 0.05);
|
||||
|
||||
const botShareNote = botShare?.botPct != null
|
||||
? `bot_fdt_pct=${(botShare.botPct * 100).toFixed(0)}%`
|
||||
: 'bot_fdt_pct=unknown';
|
||||
|
||||
return [{
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: [],
|
||||
priority,
|
||||
confidence,
|
||||
o11ySignal: edgeCost != null
|
||||
? `edge_cost=${edgeCost.toFixed(0)},bot_protection=disabled,${botShareNote},${ruleNote}`
|
||||
: `requests=${totalRequests},bot_protection=disabled,${botShareNote},${ruleNote}`,
|
||||
reason: botShare?.botPct != null && botShare.botPct > 0.2
|
||||
? 'BotID disabled with observable bot bandwidth share'
|
||||
: 'BotID disabled with observable traffic',
|
||||
question: botShare?.botPct != null && botShare.botPct > 0.2
|
||||
? `Bot traffic accounts for ${(botShare.botPct * 100).toFixed(0)}% of FDT bytes (top category: ${botShare.topCategory ?? 'unknown'}). Would enabling BotID + a challenge rule reduce that share?`
|
||||
: 'Would enabling BotID (Bot Protection) reduce edge request volume from automated traffic?',
|
||||
evidence: {
|
||||
botEnabled: false,
|
||||
edgeCost,
|
||||
totalRequests,
|
||||
managedRules: challengeRule ?? null,
|
||||
botShare: botShare ?? null,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
function totalRequestsFromSignals(signals) {
|
||||
const rows = signals.metrics?.requestsByRouteCache?.rows;
|
||||
if (!Array.isArray(rows)) return 0;
|
||||
return rows.reduce((s, r) => s + (r.value ?? 0), 0);
|
||||
}
|
||||
|
||||
// CLI convention: bot_category="" means "not classified as a bot" (human + unclassified); any non-empty = bot.
|
||||
function computeBotShare(signals) {
|
||||
const rows = signals.metrics?.fdtByBot?.rows;
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
let humanBytes = 0;
|
||||
let botBytes = 0;
|
||||
let topCategory = null;
|
||||
let topBytes = 0;
|
||||
for (const r of rows) {
|
||||
const v = r.value ?? 0;
|
||||
const cat = r.bot_category ?? '';
|
||||
if (cat === '') {
|
||||
humanBytes += v;
|
||||
} else {
|
||||
botBytes += v;
|
||||
if (v > topBytes) {
|
||||
topBytes = v;
|
||||
topCategory = cat;
|
||||
}
|
||||
}
|
||||
}
|
||||
const total = humanBytes + botBytes;
|
||||
if (total < MIN_TOTAL_FDT_BYTES) return null;
|
||||
return { humanBytes, botBytes, botPct: botBytes / total, topCategory };
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
// Second branch (slow p95 + traffic floor) keeps the gate useful on teams where
|
||||
// cold-start isn't directly observable — common on CLI v53 — trading specificity for coverage.
|
||||
export const metadata = {
|
||||
id: 'platform_fluid_compute',
|
||||
threshold: 'fluid=false AND (any cold_start signal OR any route with p95>1000ms AND inv>1000)',
|
||||
billingDimension: 'function-duration',
|
||||
scope: 'account',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'When Fluid Compute is disabled on a project that shows cold-start pressure (high cold-start rate) or sustained slow function p95 on hot routes. Fluid Compute reduces cold starts via instance reuse — recommend turning it on at the project level rather than per-route.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
// If project config failed to load we can't tell if Fluid is on; recommending it when already-on
|
||||
// erodes trust badly, so stay silent and let Strengths note the gap.
|
||||
if (signals.project?.error) return [];
|
||||
|
||||
const fluidEnabled =
|
||||
signals.project?.resourceConfig?.fluid === true
|
||||
|| signals.project?.defaultResourceConfig?.fluid === true;
|
||||
if (fluidEnabled) return [];
|
||||
|
||||
const cold = extractHighColdRoutes(signals);
|
||||
const slow = extractSlowHotRoutes(signals);
|
||||
if (cold.length === 0 && slow.length === 0) return [];
|
||||
|
||||
return [{
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: [],
|
||||
priority: 50,
|
||||
confidence: cold.length > 0 ? 0.85 : 0.65,
|
||||
o11ySignal: cold.length > 0
|
||||
? `${cold.length} route(s) with high cold-start rate`
|
||||
: `${slow.length} hot route(s) with p95>1s; cold-start not directly observable`,
|
||||
reason: cold.length > 0
|
||||
? 'cold starts observed and Fluid Compute is disabled'
|
||||
: 'slow hot routes and Fluid Compute is disabled',
|
||||
question: 'Would enabling Fluid Compute reduce cold-start and warm-instance reuse overhead for the observed hot routes?',
|
||||
evidence: { fluidEnabled, highColdRoutes: cold.slice(0, 5), slowHotRoutes: slow.slice(0, 5) },
|
||||
}];
|
||||
}
|
||||
|
||||
function extractHighColdRoutes(signals) {
|
||||
const live = signals.metrics?.fnStartTypeByRoute?.rows;
|
||||
if (Array.isArray(live) && live.some((r) => 'coldCount' in r || 'coldPct' in r)) {
|
||||
return live.filter((r) => r.route && (r.coldPct ?? 0) > 0.3 && (r.total ?? 0) > 100);
|
||||
}
|
||||
// Legacy pre-derived fixture shape.
|
||||
const direct = signals.metrics?.coldStartByRoute?.rows;
|
||||
if (Array.isArray(direct)) {
|
||||
return direct.filter((r) => r.route && (r.coldPct ?? 0) > 0.3 && (r.total ?? 0) > 100);
|
||||
}
|
||||
const legacy = signals.metrics?.coldStarts?.series;
|
||||
if (Array.isArray(legacy)) {
|
||||
return legacy
|
||||
.map((s) => {
|
||||
const total = s.summary?.count ?? 0;
|
||||
const coldCount = s.summary?.coldCount ?? s.summary?.sum ?? 0;
|
||||
return { route: s.groupValues?.route, total, coldPct: total > 0 ? coldCount / total : 0 };
|
||||
})
|
||||
.filter((r) => r.route && r.coldPct > 0.3 && r.total > 100);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractSlowHotRoutes(signals) {
|
||||
const dur = signals.metrics?.fnDurationP95ByRoute?.rows;
|
||||
const cache = signals.metrics?.requestsByRouteCache?.rows;
|
||||
if (!Array.isArray(dur)) return [];
|
||||
|
||||
// Sum requests per route across cache_result.
|
||||
const inv = new Map();
|
||||
for (const r of (cache ?? [])) {
|
||||
if (!r.route) continue;
|
||||
inv.set(r.route, (inv.get(r.route) ?? 0) + (r.value ?? 0));
|
||||
}
|
||||
return dur
|
||||
.filter((r) => r.route)
|
||||
.map((r) => ({ route: r.route, p95Ms: Math.round(r.value ?? 0), invocations: inv.get(r.route) ?? 0 }))
|
||||
// inv>500 floor is the 14d-window equivalent of the old 1000/30d.
|
||||
.filter((r) => r.p95Ms > 1000 && r.invocations > 500);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Region-misconfig gate. Branch 2 (scanner-only) — per-region TTFB data gap.
|
||||
//
|
||||
// The intended Branch 1 (region-grouped TTFB metric) was preflight-tested but the
|
||||
// CLI returned INTERNAL_ERROR for the `--group-by route --group-by function_region`
|
||||
// combination, and SAML re-auth blocked single-dim verification (see Phase 0 in
|
||||
// plans/wild-splashing-flamingo.md). Ship scanner-only with `evidence.dataGap` and
|
||||
// add the query later when verifiable.
|
||||
//
|
||||
// Fires when a single-region pin is found AND the project has meaningful surface area
|
||||
// (routes.length > 20). Skips multi-region configs (informational only).
|
||||
export const metadata = {
|
||||
id: 'region_misconfig',
|
||||
threshold: 'single-region pin found AND routes.length > 20 (scanner-only branch)',
|
||||
billingDimension: 'function-duration',
|
||||
scope: 'account',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
"A single function region is pinned in `vercel.json` or per-route `preferredRegion`. Without per-region TTFB data (data gap), the gate can't quantify the geographic latency cost — but a single-region pin on a project with 20+ routes is worth auditing against Speed Insights traffic geo.",
|
||||
};
|
||||
|
||||
const ROUTE_FLOOR = 20;
|
||||
const SCANNER_PATTERN = 'region-pin-in-config';
|
||||
|
||||
export function gate(signals) {
|
||||
const findings = (signals?.codebase?.findings ?? []).filter((f) => f.pattern === SCANNER_PATTERN);
|
||||
if (findings.length === 0) return [];
|
||||
|
||||
const routes = signals?.codebase?.routes ?? [];
|
||||
if (routes.length < ROUTE_FLOOR) return [];
|
||||
|
||||
const singleRegionFindings = findings.filter((f) => Array.isArray(f.regions) && f.regions.length === 1);
|
||||
if (singleRegionFindings.length === 0) return [];
|
||||
|
||||
const allPinned = new Set();
|
||||
for (const f of singleRegionFindings) {
|
||||
for (const r of f.regions ?? []) allPinned.add(r);
|
||||
}
|
||||
const regionList = [...allPinned];
|
||||
|
||||
// If multiple distinct single-region pins exist across files, the surface is partly
|
||||
// multi-region by accident; that's noteworthy but lower priority.
|
||||
const homogeneous = regionList.length === 1;
|
||||
|
||||
return [{
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: singleRegionFindings.map((f) => f.file).slice(0, 6),
|
||||
priority: homogeneous ? 42 : 38,
|
||||
confidence: 0.6, // low — no per-region TTFB data
|
||||
o11ySignal: `pinned_regions=${regionList.join(',')} routes=${routes.length}`,
|
||||
reason: homogeneous
|
||||
? `all functions pinned to a single region (${regionList[0]}) on a project with ${routes.length} routes`
|
||||
: `${regionList.length} different single-region pins across files`,
|
||||
question: 'Are the pinned function regions aligned with the dominant user geography and the data source location? Speed Insights TTFB-by-country can ground the comparison.',
|
||||
evidence: {
|
||||
metric: 'codebase.findings',
|
||||
pinnedRegions: regionList,
|
||||
findingsCount: singleRegionFindings.length,
|
||||
routeCount: routes.length,
|
||||
sampleFiles: singleRegionFindings.slice(0, 3).map((f) => ({ file: f.file, regions: f.regions, subtype: f.subtype })),
|
||||
dataGap: 'region-grouped-TTFB-unavailable',
|
||||
},
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Errored function invocations still bill at full duration, so high-volume 5xx is a cost issue, not just reliability.
|
||||
import { withRouteShapeWarnings } from '../route-normalize.mjs';
|
||||
|
||||
const MIN_VOLUME_FOR_RATE_EMISSION = 1000;
|
||||
|
||||
export const metadata = {
|
||||
id: 'route_errors',
|
||||
threshold: `count > 250 OR (totalRequests >= ${MIN_VOLUME_FOR_RATE_EMISSION} AND errorRate > 0.01)`,
|
||||
billingDimension: 'function-duration',
|
||||
scope: 'route',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Routes producing > 250 5xx errors over the window, or with > 1% error rate on at least 1,000 total requests. Errored function invocations still bill at full duration; high error rates also poison user experience.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const errors = extractErrors(signals);
|
||||
return errors
|
||||
.filter((e) => e.count > 250 || (e.total >= MIN_VOLUME_FOR_RATE_EMISSION && (e.errorRate ?? 0) > 0.01))
|
||||
.map((e) => withRouteShapeWarnings({
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route: e.route,
|
||||
files: [],
|
||||
priority: e.count,
|
||||
confidence: 0.93,
|
||||
o11ySignal: e.errorRate != null
|
||||
? `errs=${e.count},rate=${(e.errorRate * 100).toFixed(1)}%`
|
||||
: `errs=${e.count}`,
|
||||
reason: 'concentrated 5xx errors',
|
||||
question: `Why does ${e.route} produce ${e.count} 5xx errors over the window, and what code path is failing?`,
|
||||
evidence: { metric: e.metric, route: e.route, count: e.count, totalRequests: e.total, errorRate: e.errorRate },
|
||||
}, signals));
|
||||
}
|
||||
|
||||
function extractErrors(signals) {
|
||||
const fnStatus = signals.metrics?.fnStatusByRoute;
|
||||
if (Array.isArray(fnStatus?.rows)) return extractFromStatusRows(fnStatus.rows, 'fnStatusByRoute');
|
||||
|
||||
const m = signals.metrics?.requestsByRouteStatus;
|
||||
const cache = signals.metrics?.requestsByRouteCache;
|
||||
if (!m?.ok && !Array.isArray(m?.rows)) return [];
|
||||
|
||||
const errors = extractFromStatusRows(m?.rows ?? [], 'requestsByRouteStatus');
|
||||
|
||||
// cache rollup is summed across cache_result, giving per-route total request count.
|
||||
const totalByRoute = new Map();
|
||||
for (const row of (cache?.rows ?? [])) {
|
||||
if (!row.route) continue;
|
||||
totalByRoute.set(row.route, (totalByRoute.get(row.route) ?? 0) + (row.value ?? 0));
|
||||
}
|
||||
|
||||
return errors.map((e) => {
|
||||
const total = totalByRoute.get(e.route) ?? 0;
|
||||
return {
|
||||
...e,
|
||||
total,
|
||||
errorRate: total > 0 ? e.count / total : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractFromStatusRows(rows, metric) {
|
||||
const errByRoute = new Map();
|
||||
const totalByRoute = new Map();
|
||||
for (const row of rows) {
|
||||
const route = row.route;
|
||||
if (!route) continue;
|
||||
const v = row.value ?? 0;
|
||||
const status = String(row.http_status ?? '');
|
||||
if (/^5\d\d$/.test(status)) errByRoute.set(route, (errByRoute.get(route) ?? 0) + v);
|
||||
totalByRoute.set(route, (totalByRoute.get(route) ?? 0) + v);
|
||||
}
|
||||
|
||||
return [...errByRoute.entries()].map(([route, count]) => {
|
||||
const total = totalByRoute.get(route) ?? 0;
|
||||
const errorRate = total > 0 ? count / total : null;
|
||||
return { route, count, total, errorRate, metric };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Signal source is the codebase itself, not traffic. COLD-PATH and NO-ROUTE-MAPPING findings
|
||||
// are dropped unless the scanner sets trafficIndependent (build configs, middleware matchers, etc.).
|
||||
// Annotation happens in scan-codebase.mjs; gates here just read scanner.o11ySignal.
|
||||
|
||||
export const SCANNER_GATES = [
|
||||
{ id: 'image_optimization', patterns: ['unoptimized-image'], threshold: 2,
|
||||
billingDimension: 'image-optimization', priority: 30 },
|
||||
{ id: 'cache_header_gap', patterns: ['max-age-without-s-maxage', 'missing-cache-headers'], threshold: 1,
|
||||
billingDimension: 'edge-requests', priority: 40 },
|
||||
{ id: 'rendering_candidate', patterns: ['force-dynamic', 'headers-in-page'], threshold: 3,
|
||||
billingDimension: 'function-duration', priority: 35 },
|
||||
{ id: 'use_cache_date_stamp', patterns: ['use-cache-date-stamp'], threshold: 1,
|
||||
billingDimension: 'isr', priority: 45 },
|
||||
{ id: 'cache_components_suspense_dedupe', patterns: ['cache-components-suspense-dedupe'], threshold: 1,
|
||||
billingDimension: 'function-duration', priority: 38 },
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
id: 'scanner-driven',
|
||||
threshold: 'per-kind: scanner matches.length >= threshold',
|
||||
billingDimension: 'mixed',
|
||||
scope: 'mixed',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Configured kinds emitted from scanner output. Each requires a minimum match count to avoid noise. Findings on cold-path or unmappable files are dropped unless the underlying scanner is trafficIndependent.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const findings = signals.codebase?.findings ?? [];
|
||||
if (findings.length === 0) return [];
|
||||
|
||||
const candidates = [];
|
||||
|
||||
for (const cfg of SCANNER_GATES) {
|
||||
const matched = findings.filter((f) => {
|
||||
if (!cfg.patterns.includes(f.pattern)) return false;
|
||||
if (!f.trafficIndependent) {
|
||||
if (!f.o11ySignal || f.o11ySignal === 'scanner-only') return false;
|
||||
if (f.o11ySignal === 'COLD-PATH') return false;
|
||||
if (f.o11ySignal === 'NO-ROUTE-MAPPING') return false;
|
||||
}
|
||||
if (cfg.id === 'cache_header_gap' && observedCacheHitRate(f.o11ySignal) >= 90) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const group of groupFindings(cfg, matched)) {
|
||||
if (group.findings.length < cfg.threshold) continue;
|
||||
candidates.push(candidateForGroup(cfg, group));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function groupFindings(cfg, findings) {
|
||||
const groups = new Map();
|
||||
for (const finding of findings) {
|
||||
const scope = finding.route ? 'route' : 'file';
|
||||
const target = scope === 'route' ? finding.route : finding.file;
|
||||
if (!target) continue;
|
||||
const key = `${cfg.id}:${scope}:${target}`;
|
||||
if (!groups.has(key)) groups.set(key, { scope, target, findings: [] });
|
||||
groups.get(key).findings.push(finding);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function candidateForGroup(cfg, group) {
|
||||
const matched = group.findings;
|
||||
const route = group.scope === 'route' ? group.target : null;
|
||||
return {
|
||||
kind: cfg.id,
|
||||
scope: group.scope,
|
||||
route,
|
||||
files: uniqueStrings(matched.map((m) => m.file)).slice(0, 6),
|
||||
priority: cfg.priority + Math.min(matched.length, 10),
|
||||
confidence: 0.88,
|
||||
o11ySignal: matched
|
||||
.map((m) => m.o11ySignal)
|
||||
.find((s) => s && s !== 'COLD-PATH' && s !== 'NO-ROUTE-MAPPING')
|
||||
?? 'scanner-only',
|
||||
reason: `${matched.length} ${cfg.patterns.join('+')} finding(s)`,
|
||||
question: questionFor(cfg.id, matched),
|
||||
evidence: {
|
||||
scannerMatches: matched.length,
|
||||
patterns: cfg.patterns,
|
||||
scope: group.scope,
|
||||
route,
|
||||
sampleFiles: matched.slice(0, 3).map((m) => ({ file: m.file, line: m.line })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function questionFor(kindId, matched) {
|
||||
const sample = matched.slice(0, 3).map((m) => m.file).join(', ');
|
||||
switch (kindId) {
|
||||
case 'image_optimization':
|
||||
return `Which raw <img> tags in ${sample} should move to next/image (or the framework's image component)?`;
|
||||
case 'cache_header_gap':
|
||||
return `Should the route handlers in ${sample} set Cache-Control with s-maxage to serve from the CDN?`;
|
||||
case 'rendering_candidate':
|
||||
return `Why are the routes in ${sample} forced to dynamic rendering, and can any of them tolerate ISR or static generation?`;
|
||||
case 'use_cache_date_stamp':
|
||||
return `Which 'use cache' boundaries in ${sample} embed new Date()/Date.now()/Math.random() that destabilizes cache keys, and can the timestamps be hoisted to a build constant or moved into a client useEffect?`;
|
||||
case 'cache_components_suspense_dedupe':
|
||||
return `In ${sample}, which repeated fetch or helper is being re-invoked across separate <Suspense> boundaries, and can the promise be hoisted to the page level or moved to 'use cache: remote' for cross-boundary dedupe?`;
|
||||
default:
|
||||
return `Investigate ${matched.length} ${kindId} finding(s).`;
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set(values.filter((v) => typeof v === 'string' && v.length > 0))];
|
||||
}
|
||||
|
||||
function observedCacheHitRate(signal) {
|
||||
if (typeof signal !== 'string') return null;
|
||||
const m = /\bcache=([\d.]+)%/.exec(signal);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Deterministic launch selection for the code-scope investigation budget.
|
||||
//
|
||||
// Raw priority still orders candidates inside each pass. The default budget is
|
||||
// impact-first, with failure-mode diversity when a kind's top signal is large
|
||||
// enough to justify taking a first-pass slot.
|
||||
|
||||
const DEFAULT_KIND_CAPS = new Map([
|
||||
['slow_route', 2],
|
||||
['uncached_route', 2],
|
||||
['route_errors', 2],
|
||||
]);
|
||||
|
||||
const DIVERSITY_ELIGIBILITY = new Map([
|
||||
// A handful of 5xx errors can pass the route_errors gate because the rate is
|
||||
// high, but that should not displace much larger cost/performance signals in
|
||||
// the default six-candidate pass.
|
||||
['route_errors', (candidate) => numberFromEvidence(candidate, 'count') >= 1000],
|
||||
|
||||
// Scanner-driven cache findings are valuable, but the default pass should
|
||||
// spend a slot only when observability shows meaningful route traffic or a
|
||||
// very slow route handler.
|
||||
['cache_header_gap', (candidate) => {
|
||||
const invocations = numberFromSignal(candidate?.o11ySignal, 'inv');
|
||||
const p95Ms = durationMsFromSignal(candidate?.o11ySignal, 'p95');
|
||||
return invocations >= 50_000 || p95Ms >= 2000;
|
||||
}],
|
||||
['rendering_candidate', (candidate) => numberFromSignal(candidate?.o11ySignal, 'inv') >= 50_000],
|
||||
]);
|
||||
|
||||
export function selectLaunchCandidates(candidates, budget, { diversify = false } = {}) {
|
||||
const pool = Array.isArray(candidates) ? candidates : [];
|
||||
if (budget === Infinity) {
|
||||
return { selected: pool, skipped: [], selectionMode: 'all' };
|
||||
}
|
||||
if (!Number.isInteger(budget) || budget < 1) {
|
||||
throw new TypeError('selectLaunchCandidates budget must be a positive integer or Infinity');
|
||||
}
|
||||
if (!diversify) {
|
||||
return {
|
||||
selected: pool.slice(0, budget),
|
||||
skipped: pool.slice(budget),
|
||||
selectionMode: 'priority',
|
||||
};
|
||||
}
|
||||
|
||||
const selected = [];
|
||||
const selectedKeys = new Set();
|
||||
const countsByKind = new Map();
|
||||
|
||||
const add = (candidate) => {
|
||||
const key = candidateIdentity(candidate);
|
||||
if (selectedKeys.has(key)) return false;
|
||||
selectedKeys.add(key);
|
||||
selected.push(candidate);
|
||||
const kind = candidate.kind ?? '<unknown>';
|
||||
countsByKind.set(kind, (countsByKind.get(kind) ?? 0) + 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
// First pass: one candidate per failure mode, preserving the existing sorted
|
||||
// order. This is where the default run gets broad coverage, but only for
|
||||
// kinds whose signal is strong enough for a default slot.
|
||||
for (const candidate of pool) {
|
||||
if (selected.length >= budget) break;
|
||||
const kind = candidate.kind ?? '<unknown>';
|
||||
if ((countsByKind.get(kind) ?? 0) > 0) continue;
|
||||
if (!isDiversityEligible(candidate)) continue;
|
||||
add(candidate);
|
||||
}
|
||||
|
||||
// Second pass: allow a second entry for high-frequency families, but avoid
|
||||
// letting slow_route consume the entire default budget when other kinds exist.
|
||||
for (const candidate of pool) {
|
||||
if (selected.length >= budget) break;
|
||||
const kind = candidate.kind ?? '<unknown>';
|
||||
const cap = DEFAULT_KIND_CAPS.get(kind) ?? 1;
|
||||
if ((countsByKind.get(kind) ?? 0) >= cap) continue;
|
||||
if (!isDiversityEligible(candidate)) continue;
|
||||
add(candidate);
|
||||
}
|
||||
|
||||
// Final fill: if the project only has one or two candidate kinds, use the
|
||||
// whole requested budget rather than leaving slots empty.
|
||||
for (const candidate of pool) {
|
||||
if (selected.length >= budget) break;
|
||||
add(candidate);
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
skipped: pool.filter((candidate) => !selectedKeys.has(candidateIdentity(candidate))),
|
||||
selectionMode: 'diverse-default',
|
||||
};
|
||||
}
|
||||
|
||||
function candidateIdentity(candidate) {
|
||||
return [
|
||||
candidate?.kind ?? '',
|
||||
candidate?.route ?? '',
|
||||
candidate?.hostname ?? '',
|
||||
candidate?.scope ?? '',
|
||||
candidate?.o11ySignal ?? '',
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
function isDiversityEligible(candidate) {
|
||||
const fn = DIVERSITY_ELIGIBILITY.get(candidate?.kind);
|
||||
return fn ? fn(candidate) : true;
|
||||
}
|
||||
|
||||
function numberFromEvidence(candidate, key) {
|
||||
const value = candidate?.evidence?.[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function numberFromSignal(signal, key) {
|
||||
if (typeof signal !== 'string') return 0;
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(?:^|,)${escaped}=([\\d.]+)`);
|
||||
const m = re.exec(signal);
|
||||
if (!m) return 0;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function durationMsFromSignal(signal, key) {
|
||||
if (typeof signal !== 'string') return 0;
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(?:^|,)${escaped}=([\\d.]+)ms`);
|
||||
const m = re.exec(signal);
|
||||
if (!m) return 0;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Primary threshold (p95>500 AND inv>=1400) WHY: 1.4k/14d is the floor where p95 stabilizes statistically
|
||||
// and a 3-5x performance win still pays for engineering time. Secondary (p95>1500 AND inv>=250) catches "catastrophically
|
||||
// slow at any volume" — usually a broken sync call or cold-start chain the customer wants to know about.
|
||||
//
|
||||
// 5xx disqualifier: when error rate >50% the route is failing, not slow — latency reflects crash time,
|
||||
// not work time. route_errors covers it independently; we disqualify here so budget isn't spent on a
|
||||
// sub-agent that will correctly abstain.
|
||||
|
||||
import { withRouteShapeWarnings } from '../route-normalize.mjs';
|
||||
|
||||
const ERROR_RATE_DISQUALIFY_THRESHOLD = 0.5;
|
||||
|
||||
export const metadata = {
|
||||
id: 'slow_route',
|
||||
threshold: '(p95 > 500 AND inv >= 1400) OR (p95 > 1500 AND inv >= 250); disqualified when 5xx rate > 50%; Vercel Workflow runtime endpoints are hard-gated',
|
||||
billingDimension: 'function-duration',
|
||||
scope: 'route',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Routes with p95 function duration above 500ms at meaningful traffic (>=1,400 invocations in window), OR catastrophically slow routes (>1500ms p95 at any volume >=250). High duration drives both function-duration cost and user-perceived latency. Investigate sequential awaits, slow external APIs, missing caching, N+1 patterns. Routes with >50% 5xx rate are disqualified — those are reliability problems, not performance tuning targets, and surface via route_errors instead. Vercel Workflow runtime endpoints (`/.well-known/workflow/v1/*`) are hard-gated before launch because long-running step/flow requests are expected orchestration, not app-route bottlenecks.',
|
||||
};
|
||||
|
||||
export function gate(signals) {
|
||||
const routes = extractFunctionRoutes(signals);
|
||||
const errorRates = extractErrorRatesByRoute(signals);
|
||||
return routes
|
||||
.filter((r) => (r.p95Ms > 500 && r.invocations >= 1400) || (r.p95Ms > 1500 && r.invocations >= 250))
|
||||
.map((r) => {
|
||||
const errorRate = errorRates.get(r.route);
|
||||
const candidate = {
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route: r.route,
|
||||
files: [],
|
||||
priority: Math.round(r.p95Ms * Math.max(r.invocations, 1) / 1000),
|
||||
confidence: 0.94,
|
||||
o11ySignal: `inv=${r.invocations},p95=${r.p95Ms}ms${errorRate != null ? `,5xx=${(errorRate * 100).toFixed(0)}%` : ''}`,
|
||||
reason: 'slow high-traffic route',
|
||||
question: `What is the concrete bottleneck in ${r.route} (p95=${r.p95Ms}ms over ${r.invocations} invocations), and which file-level change would reduce it?`,
|
||||
evidence: { metric: 'fnDurationP95ByRoute', route: r.route, p95Ms: r.p95Ms, invocations: r.invocations, errorRate },
|
||||
};
|
||||
if (errorRate != null && errorRate > ERROR_RATE_DISQUALIFY_THRESHOLD) {
|
||||
candidate.disqualified = true;
|
||||
candidate.disqualifyReason = `high error rate (${(errorRate * 100).toFixed(0)}% 5xx — reliability issue, not performance; covered by route_errors gate)`;
|
||||
}
|
||||
return withRouteShapeWarnings(candidate, signals);
|
||||
});
|
||||
}
|
||||
|
||||
// Routes without status data are absent from the map → gate falls back to "no disqualification".
|
||||
function extractErrorRatesByRoute(signals) {
|
||||
const m = signals.metrics?.fnStatusByRoute;
|
||||
const out = new Map();
|
||||
if (!Array.isArray(m?.rows)) return out;
|
||||
const perRoute = new Map();
|
||||
for (const row of m.rows) {
|
||||
if (!row?.route) continue;
|
||||
const v = row.value ?? 0;
|
||||
const prior = perRoute.get(row.route) ?? { errors5xx: 0, total: 0 };
|
||||
if (/^5/.test(String(row.http_status ?? ''))) prior.errors5xx += v;
|
||||
prior.total += v;
|
||||
perRoute.set(row.route, prior);
|
||||
}
|
||||
for (const [route, r] of perRoute) {
|
||||
if (r.total > 0) out.set(route, r.errors5xx / r.total);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractFunctionRoutes(signals) {
|
||||
const dur = signals.metrics?.fnDurationP95ByRoute;
|
||||
if (!dur?.ok && !Array.isArray(dur?.rows)) return [];
|
||||
const req = signals.metrics?.requestsByRouteCache;
|
||||
|
||||
const invByRoute = new Map();
|
||||
for (const row of (req?.rows ?? [])) {
|
||||
if (!row.route) continue;
|
||||
invByRoute.set(row.route, (invByRoute.get(row.route) ?? 0) + (row.value ?? 0));
|
||||
}
|
||||
|
||||
return (dur?.rows ?? [])
|
||||
.filter((r) => r.route)
|
||||
.map((r) => ({
|
||||
route: r.route,
|
||||
p95Ms: Math.round(r.value ?? 0),
|
||||
invocations: invByRoute.get(r.route) ?? 0,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type CandidateScope = 'route' | 'file' | 'account';
|
||||
|
||||
export interface GateMetadata {
|
||||
id: string;
|
||||
threshold: string;
|
||||
billingDimension: string;
|
||||
scope: CandidateScope | 'mixed';
|
||||
sourceCitation?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Candidate {
|
||||
kind: string;
|
||||
scope: CandidateScope;
|
||||
route?: string | null;
|
||||
hostname?: string | null;
|
||||
files: string[];
|
||||
priority: number;
|
||||
confidence: number;
|
||||
o11ySignal?: string;
|
||||
reason: string;
|
||||
question: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
disqualified?: boolean;
|
||||
disqualifyReason?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface Signals {
|
||||
metrics?: Record<string, unknown>;
|
||||
codebase?: {
|
||||
findings?: Array<Record<string, unknown>>;
|
||||
routes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
project?: Record<string, unknown>;
|
||||
usage?: Record<string, unknown>;
|
||||
stack?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// getShare>0.20 filter WHY: a route that's >80% POST/PUT/DELETE is a mutation endpoint
|
||||
// where 0% cache is correct — recommending caching there is wrong.
|
||||
// cache_result values STALE/REVALIDATED/BYPASS fold into "total but not HIT" — matches the "uncached" framing.
|
||||
|
||||
import { withRouteShapeWarnings } from '../route-normalize.mjs';
|
||||
|
||||
const MIN_GET_SHARE = 0.20;
|
||||
|
||||
/** @type {import('./types.d.ts').GateMetadata} */
|
||||
export const metadata = {
|
||||
id: 'uncached_route',
|
||||
threshold: `requests > 500 AND hitRate < 0.5 AND getShare > ${MIN_GET_SHARE} (missing getShare is gated)`,
|
||||
billingDimension: 'edge-requests',
|
||||
scope: 'route',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'Routes serving > 500 requests/period at < 50% cache hit AND at least 20% GET traffic. Each uncached GET request reaches the function, costing edge requests + function duration. Routes that are mostly POST/PUT/DELETE (Server Actions, mutations) are skipped — 0% cache is correct behavior there. Routes with missing method-share data are gated instead of launched. Auth-gated routes are disqualified separately.',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('./types.d.ts').Signals} signals
|
||||
* @returns {import('./types.d.ts').Candidate[]}
|
||||
*/
|
||||
export function gate(signals) {
|
||||
const rates = extractCacheHitRates(signals);
|
||||
const methods = extractMethodShares(signals);
|
||||
return rates
|
||||
.map((r) => ({ ...r, getShare: methods.get(r.route) ?? null }))
|
||||
.filter((r) => r.requests > 500 && r.hitRate < 0.5)
|
||||
.filter((r) => r.getShare === null || r.getShare > MIN_GET_SHARE)
|
||||
.map((r) => {
|
||||
const candidate = withRouteShapeWarnings({
|
||||
kind: metadata.id,
|
||||
scope: 'route',
|
||||
route: r.route,
|
||||
files: [],
|
||||
priority: Math.round(r.requests * (1 - r.hitRate)),
|
||||
confidence: r.getShare === null ? 0.5 : 0.92,
|
||||
o11ySignal: `requests=${r.requests},cache=${(r.hitRate * 100).toFixed(0)}%${r.getShare !== null ? `,get=${(r.getShare * 100).toFixed(0)}%` : ''}`,
|
||||
reason: 'uncached high-traffic route',
|
||||
question: `Why does ${r.route} have ${(r.hitRate * 100).toFixed(0)}% cache hit rate on ${r.requests} requests in this metrics window, and is it safe to cache at the edge?`,
|
||||
evidence: { metric: 'requestsByRouteCache', route: r.route, requests: r.requests, hitRate: r.hitRate, getShare: r.getShare },
|
||||
}, signals);
|
||||
if (r.getShare !== null) return candidate;
|
||||
return {
|
||||
...candidate,
|
||||
disqualified: true,
|
||||
disqualifyReason: 'missing GET-share data — route method mix is required before recommending edge caching',
|
||||
warnings: [...new Set([...(candidate.warnings ?? []), 'method-share:missing'])],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractCacheHitRates(signals) {
|
||||
const m = signals.metrics?.requestsByRouteCache;
|
||||
if (!m?.ok && !Array.isArray(m?.rows)) return [];
|
||||
|
||||
const perRoute = new Map();
|
||||
for (const row of (m?.rows ?? [])) {
|
||||
const route = row.route;
|
||||
if (!route) continue;
|
||||
const value = row.value ?? 0;
|
||||
const prior = perRoute.get(route) ?? { route, hits: 0, total: 0 };
|
||||
if (row.cache_result === 'HIT') prior.hits += value;
|
||||
prior.total += value;
|
||||
perRoute.set(route, prior);
|
||||
}
|
||||
|
||||
return [...perRoute.values()].map((r) => ({
|
||||
route: r.route,
|
||||
requests: r.total,
|
||||
hitRate: r.total > 0 ? r.hits / r.total : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function extractMethodShares(signals) {
|
||||
const m = signals.metrics?.requestsByRouteMethod;
|
||||
const out = new Map();
|
||||
if (!Array.isArray(m?.rows)) return out;
|
||||
const perRoute = new Map();
|
||||
for (const row of m.rows) {
|
||||
if (!row?.route) continue;
|
||||
const v = row.value ?? 0;
|
||||
const prior = perRoute.get(row.route) ?? { gets: 0, total: 0 };
|
||||
if ((row.request_method ?? '').toUpperCase() === 'GET') prior.gets += v;
|
||||
prior.total += v;
|
||||
perRoute.set(row.route, prior);
|
||||
}
|
||||
for (const [route, r] of perRoute) {
|
||||
if (r.total > 0) out.set(route, r.gets / r.total);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Detects per-day billing spikes by inspecting usage.breakdown.data[] (daily granularity).
|
||||
// Fires when any single day's total bill > 2× the window mean, OR a single SKU's day value > 3× its window mean.
|
||||
// Emits one candidate per spiking SKU (or 'total' when the spike is broad).
|
||||
// Degrades gracefully when daily data is unavailable — common path because the skill prefers --group-by project, which omits daily breakdown.
|
||||
export const metadata = {
|
||||
id: 'usage_spike_triage',
|
||||
threshold: 'any-day total > 2x mean OR any-day SKU > 3x SKU mean',
|
||||
billingDimension: 'mixed',
|
||||
scope: 'account',
|
||||
sourceCitation: 'vercel-optimize gate threshold',
|
||||
description:
|
||||
'A single day in the billing window deviates sharply from the window baseline. Triage branches: bot or AI crawler spike, viral moment, pricing-model migration (legacy SKU → new), code regression. Without daily-granularity data, this gate stays dormant.',
|
||||
};
|
||||
|
||||
const TOTAL_MULTIPLIER = 2;
|
||||
const SKU_MULTIPLIER = 3;
|
||||
const MIN_BILLED_FLOOR = 5; // skip spikes whose absolute value is too small to matter
|
||||
|
||||
export function gate(signals) {
|
||||
const days = signals?.usage?.breakdown?.data;
|
||||
if (!Array.isArray(days) || days.length < 3) return [];
|
||||
|
||||
const dayTotals = days.map(dayTotal);
|
||||
const mean = dayTotals.reduce((a, b) => a + b, 0) / dayTotals.length;
|
||||
if (mean <= MIN_BILLED_FLOOR) return [];
|
||||
|
||||
const totalSpikeDays = dayTotals
|
||||
.map((value, idx) => ({ idx, value }))
|
||||
.filter((d) => d.value > mean * TOTAL_MULTIPLIER && d.value > MIN_BILLED_FLOOR);
|
||||
|
||||
const skuStats = aggregateSkuStats(days);
|
||||
const skuSpikes = [];
|
||||
for (const stat of skuStats) {
|
||||
if (stat.mean <= MIN_BILLED_FLOOR) continue;
|
||||
for (const sample of stat.samples) {
|
||||
if (sample.value > stat.mean * SKU_MULTIPLIER && sample.value > MIN_BILLED_FLOOR) {
|
||||
skuSpikes.push({
|
||||
name: stat.name,
|
||||
dayIndex: sample.idx,
|
||||
dayValue: sample.value,
|
||||
skuMean: stat.mean,
|
||||
multiplier: stat.mean > 0 ? sample.value / stat.mean : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSpikeDays.length === 0 && skuSpikes.length === 0) return [];
|
||||
|
||||
const candidates = [];
|
||||
if (totalSpikeDays.length > 0) {
|
||||
const peak = totalSpikeDays.reduce((a, b) => (a.value > b.value ? a : b));
|
||||
candidates.push({
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: [],
|
||||
priority: 60,
|
||||
confidence: 0.78,
|
||||
o11ySignal: `total_spike day_idx=${peak.idx} day_billed=${peak.value.toFixed(2)} window_mean=${mean.toFixed(2)} mult=${(peak.value / mean).toFixed(1)}x`,
|
||||
reason: 'total billed cost on one day exceeds 2× the window mean',
|
||||
question: 'Which workload generated the day-over-day spike — bot or AI-crawler traffic on a cacheable route, a viral event, a pricing-model migration, or a code regression?',
|
||||
evidence: {
|
||||
metric: 'usage.breakdown.data.total',
|
||||
spikeDay: peak.idx,
|
||||
spikeBilled: peak.value,
|
||||
windowMean: mean,
|
||||
multiplier: peak.value / mean,
|
||||
skuName: 'total',
|
||||
},
|
||||
});
|
||||
}
|
||||
// Up to 3 SKU-specific candidates; the rest fold into 'multiple SKUs spiking' framing.
|
||||
const orderedSkuSpikes = skuSpikes.sort((a, b) => b.dayValue - a.dayValue).slice(0, 3);
|
||||
for (const spike of orderedSkuSpikes) {
|
||||
candidates.push({
|
||||
kind: metadata.id,
|
||||
scope: 'account',
|
||||
files: [],
|
||||
priority: 55,
|
||||
confidence: 0.78,
|
||||
o11ySignal: `sku_spike sku="${spike.name}" day_idx=${spike.dayIndex} day_billed=${spike.dayValue.toFixed(2)} sku_mean=${spike.skuMean.toFixed(2)} mult=${spike.multiplier.toFixed(1)}x`,
|
||||
reason: `${spike.name} on one day exceeds 3× its window mean`,
|
||||
question: `${spike.name} spiked ${spike.multiplier.toFixed(1)}× on day ${spike.dayIndex}. Which event (bot traffic, viral content, deploy regression, integration sync) drove it, and is the spiking SKU one the skill already covers?`,
|
||||
evidence: {
|
||||
metric: 'usage.breakdown.data.services',
|
||||
skuName: spike.name,
|
||||
spikeDay: spike.dayIndex,
|
||||
spikeBilled: spike.dayValue,
|
||||
skuMean: spike.skuMean,
|
||||
multiplier: spike.multiplier,
|
||||
},
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function dayTotal(day) {
|
||||
if (Array.isArray(day?.services)) {
|
||||
return day.services.reduce((a, s) => a + Number(s.billedCost ?? s.cost ?? 0), 0);
|
||||
}
|
||||
return Number(day?.billedCost ?? day?.cost ?? 0);
|
||||
}
|
||||
|
||||
function aggregateSkuStats(days) {
|
||||
const byName = new Map();
|
||||
days.forEach((day, idx) => {
|
||||
const services = Array.isArray(day?.services) ? day.services : [];
|
||||
for (const svc of services) {
|
||||
const name = String(svc?.name ?? '').trim();
|
||||
if (!name) continue;
|
||||
const value = Number(svc.billedCost ?? svc.cost ?? 0);
|
||||
if (!byName.has(name)) byName.set(name, { name, samples: [] });
|
||||
byName.get(name).samples.push({ idx, value });
|
||||
}
|
||||
});
|
||||
for (const stat of byName.values()) {
|
||||
const sum = stat.samples.reduce((a, s) => a + s.value, 0);
|
||||
stat.mean = stat.samples.length > 0 ? sum / stat.samples.length : 0;
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
Reference in New Issue
Block a user