88 lines
3.3 KiB
JavaScript
88 lines
3.3 KiB
JavaScript
// 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);
|
|
}
|