fix(vin-anomaly): volume_drop'u sürdürülebilir + haftanın-günü-duyarlı yap
volume_drop iki nedenle false-positive üretiyordu (2026-06-14 Pazar, decode tamamen çalışırken saatte bir −%100 "kesinti" bastı): 1. Tek 15dk pencere — düşük B2B decode trafiği patlamalı (bir kullanıcı birkaç sorgu yapar, sonra bir saat sessizlik), tek bir 0 penceresi sağlıklıyken bile normal. 2. Ardışık-7-gün baseline — decode talebinde güçlü haftalık mevsimsellik var (hafta sonu sakin); 7 gün baseline yoğun hafta içini hafta sonu beklentisine katlıyor → normal bir Pazar (~1-2/saat) hafta-içi-şişmiş ~18/saat baseline'a karşı −%100 okunuyor. Düzeltme: volume_drop artık SON 1 SAATİ, AYNI GÜN/AYNI SAAT son 4 haftanın ortalamasına karşı değerlendiriyor; yalnız o gün/saat tarihsel olarak gerçek hacim görüyorsa (≥12/saat) ve son saat bunun ≥%80 altındaysa tetikleniyor. Hafta sonu saatleri aynı-gün baseline'ı eşiğin altında kaldığı için kendiliğinden susuyor; gerçek bir hafta-içi-yoğun-saat çöküşü hâlâ tetikliyor (prod query_logs ile doğrulandı: bugünün Pazar saatleri SUSUYOR, Çarşamba 13:00 ~13/saat hâlâ izleniyor). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,21 @@ const MIN_CURRENT_VOLUME = 5; // skip checks that need volume to be meaningful
|
||||
// volume_spike and unknown_vin_spike (seen 2026-06-11: one user retrying one
|
||||
// catalogless Toyota VIN). Require at least this many distinct users.
|
||||
const MIN_DISTINCT_USERS = 2;
|
||||
// volume_drop needs a meaningful expected volume — at ~2.5 expected/window
|
||||
// (night hours) a single quiet window reads as a −100% outage.
|
||||
const MIN_EXPECTED_PER_WINDOW = 4;
|
||||
// volume_drop is evaluated over a TRAILING HOUR against a DAY-OF-WEEK-aware
|
||||
// baseline. Two failure modes of the old single-15min, consecutive-7-day version
|
||||
// (both seen 2026-06-14, a Sunday): (1) low B2B decode traffic is bursty, so a
|
||||
// lone 15-min window at 0 is normal even when healthy; (2) decode demand has
|
||||
// strong weekly seasonality (quiet weekends), so a 7-consecutive-day baseline
|
||||
// folds busy weekdays into a weekend expectation — a normal Sunday (~1-2/hr)
|
||||
// read as a −100% drop vs a weekday-inflated ~18/hr baseline. The hour window
|
||||
// fixes (1); the same-weekday baseline fixes (2).
|
||||
const HOUR_WINDOW_MIN = 60;
|
||||
// Compare against the same weekday + same hour over the last N weeks.
|
||||
const DOW_BASELINE_WEEKS = 4;
|
||||
// Below this expectation the (same-weekday) hour is too thin for a "drop" to
|
||||
// mean anything — skip volume_drop. Naturally excludes quiet weekend hours,
|
||||
// whose same-weekday baseline sits well under it.
|
||||
const MIN_EXPECTED_PER_HOUR = 12;
|
||||
// success_rate_drop is a RATE over a tiny window (~2.6 decodes/15min). It must
|
||||
// see enough DISTINCT (user,vin) lookups that aren't coverage gaps before a
|
||||
// "rate" means anything — otherwise one user retrying one catalogless VIN reads
|
||||
@@ -255,6 +267,25 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
|
||||
* For "now is 14:32–14:47", we pull 14:32–14:47 from each of yesterday and
|
||||
* the prior 6 days. Average the per-day stats.
|
||||
*/
|
||||
/**
|
||||
* Expected decode volume for the trailing hour, averaged over the same hour on
|
||||
* the same weekday across the last DOW_BASELINE_WEEKS weeks. Unlike
|
||||
* baselineStats this includes zero-volume samples (a genuinely quiet weekend
|
||||
* hour IS the expectation, not noise to drop) — that's what makes weekend hours
|
||||
* fall below MIN_EXPECTED_PER_HOUR and self-suppress.
|
||||
*/
|
||||
async function dowHourlyExpected(now: Date): Promise<number> {
|
||||
const totals: number[] = [];
|
||||
for (let w = 1; w <= DOW_BASELINE_WEEKS; w++) {
|
||||
const end = new Date(now.getTime() - w * 7 * 24 * 60 * 60_000);
|
||||
const start = new Date(end.getTime() - HOUR_WINDOW_MIN * 60_000);
|
||||
const s = await windowStats(start, end);
|
||||
totals.push(s.total);
|
||||
}
|
||||
if (totals.length === 0) return 0;
|
||||
return totals.reduce((a, b) => a + b, 0) / totals.length;
|
||||
}
|
||||
|
||||
async function baselineStats(currentEnd: Date): Promise<WindowStats> {
|
||||
const samples: WindowStats[] = [];
|
||||
for (let dayOffset = 1; dayOffset <= BASELINE_DAYS; dayOffset++) {
|
||||
@@ -350,10 +381,13 @@ export async function detectVinAnomalies(): Promise<{
|
||||
}> {
|
||||
const now = new Date();
|
||||
const currentStart = new Date(now.getTime() - CURRENT_WINDOW_MIN * 60_000);
|
||||
const hourStart = new Date(now.getTime() - HOUR_WINDOW_MIN * 60_000);
|
||||
|
||||
const [current, baseline] = await Promise.all([
|
||||
const [current, baseline, hourCurrent, expectedPerHour] = await Promise.all([
|
||||
windowStats(currentStart, now),
|
||||
baselineStats(now),
|
||||
windowStats(hourStart, now),
|
||||
dowHourlyExpected(now),
|
||||
]);
|
||||
|
||||
const hits: AnomalyHit[] = [];
|
||||
@@ -428,26 +462,23 @@ export async function detectVinAnomalies(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Volume drop: >= 80% below baseline (high), >= 95% (critical) — outage signal
|
||||
if (baseline.total >= MIN_BASELINE_VOLUME) {
|
||||
const expectedPerWindow = baseline.total / BASELINE_DAYS;
|
||||
if (
|
||||
expectedPerWindow >= MIN_EXPECTED_PER_WINDOW &&
|
||||
current.total / expectedPerWindow <= 0.2
|
||||
) {
|
||||
const dropPct = (1 - current.total / expectedPerWindow) * 100;
|
||||
hits.push({
|
||||
type: "volume_drop",
|
||||
severity: dropPct >= 95 ? "critical" : "high",
|
||||
message: `Sorgu hacmi ${expectedPerWindow.toFixed(0)} → ${current.total} (−${dropPct.toFixed(0)}%, son ${CURRENT_WINDOW_MIN}dk)`,
|
||||
baseline: expectedPerWindow,
|
||||
observed: current.total,
|
||||
current_volume: current.total,
|
||||
baseline_volume: baseline.total,
|
||||
detected_at: ts,
|
||||
dedupe_key: `vin:volume_drop:${bucket}`,
|
||||
});
|
||||
}
|
||||
// 3. Volume drop — SUSTAINED (trailing hour, not a bursty 15-min window) and
|
||||
// DAY-OF-WEEK-aware (same weekday baseline, so weekends don't read as an
|
||||
// outage). Fires only when this same-weekday hour historically sees real
|
||||
// volume (≥ MIN_EXPECTED_PER_HOUR) and the last hour is ≥80% below it.
|
||||
if (expectedPerHour >= MIN_EXPECTED_PER_HOUR && hourCurrent.total / expectedPerHour <= 0.2) {
|
||||
const dropPct = (1 - hourCurrent.total / expectedPerHour) * 100;
|
||||
hits.push({
|
||||
type: "volume_drop",
|
||||
severity: dropPct >= 95 ? "critical" : "high",
|
||||
message: `Sorgu hacmi (son 60dk) ${expectedPerHour.toFixed(0)} → ${hourCurrent.total} (−${dropPct.toFixed(0)}%, aynı gün/saat ${DOW_BASELINE_WEEKS}hf ort.)`,
|
||||
baseline: expectedPerHour,
|
||||
observed: hourCurrent.total,
|
||||
current_volume: hourCurrent.total,
|
||||
baseline_volume: Math.round(expectedPerHour),
|
||||
detected_at: ts,
|
||||
dedupe_key: `vin:volume_drop:${bucket}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Volume spike: 5x baseline — fraud or viral. One user's retry burst is
|
||||
|
||||
Reference in New Issue
Block a user