122 lines
5.0 KiB
JavaScript
122 lines
5.0 KiB
JavaScript
// 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()];
|
||
}
|