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:
Semih
2026-06-14 13:29:03 +03:00
parent 5dde24855c
commit e915dfe31c

View File

@@ -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 // volume_spike and unknown_vin_spike (seen 2026-06-11: one user retrying one
// catalogless Toyota VIN). Require at least this many distinct users. // catalogless Toyota VIN). Require at least this many distinct users.
const MIN_DISTINCT_USERS = 2; const MIN_DISTINCT_USERS = 2;
// volume_drop needs a meaningful expected volume — at ~2.5 expected/window // volume_drop is evaluated over a TRAILING HOUR against a DAY-OF-WEEK-aware
// (night hours) a single quiet window reads as a 100% outage. // baseline. Two failure modes of the old single-15min, consecutive-7-day version
const MIN_EXPECTED_PER_WINDOW = 4; // (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 // 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 // see enough DISTINCT (user,vin) lookups that aren't coverage gaps before a
// "rate" means anything — otherwise one user retrying one catalogless VIN reads // "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:3214:47", we pull 14:3214:47 from each of yesterday and * For "now is 14:3214:47", we pull 14:3214:47 from each of yesterday and
* the prior 6 days. Average the per-day stats. * 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> { async function baselineStats(currentEnd: Date): Promise<WindowStats> {
const samples: WindowStats[] = []; const samples: WindowStats[] = [];
for (let dayOffset = 1; dayOffset <= BASELINE_DAYS; dayOffset++) { for (let dayOffset = 1; dayOffset <= BASELINE_DAYS; dayOffset++) {
@@ -350,10 +381,13 @@ export async function detectVinAnomalies(): Promise<{
}> { }> {
const now = new Date(); const now = new Date();
const currentStart = new Date(now.getTime() - CURRENT_WINDOW_MIN * 60_000); 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), windowStats(currentStart, now),
baselineStats(now), baselineStats(now),
windowStats(hourStart, now),
dowHourlyExpected(now),
]); ]);
const hits: AnomalyHit[] = []; const hits: AnomalyHit[] = [];
@@ -428,26 +462,23 @@ export async function detectVinAnomalies(): Promise<{
} }
} }
// 3. Volume drop: >= 80% below baseline (high), >= 95% (critical) — outage signal // 3. Volume drop — SUSTAINED (trailing hour, not a bursty 15-min window) and
if (baseline.total >= MIN_BASELINE_VOLUME) { // DAY-OF-WEEK-aware (same weekday baseline, so weekends don't read as an
const expectedPerWindow = baseline.total / BASELINE_DAYS; // outage). Fires only when this same-weekday hour historically sees real
if ( // volume (≥ MIN_EXPECTED_PER_HOUR) and the last hour is ≥80% below it.
expectedPerWindow >= MIN_EXPECTED_PER_WINDOW && if (expectedPerHour >= MIN_EXPECTED_PER_HOUR && hourCurrent.total / expectedPerHour <= 0.2) {
current.total / expectedPerWindow <= 0.2 const dropPct = (1 - hourCurrent.total / expectedPerHour) * 100;
) { hits.push({
const dropPct = (1 - current.total / expectedPerWindow) * 100; type: "volume_drop",
hits.push({ severity: dropPct >= 95 ? "critical" : "high",
type: "volume_drop", message: `Sorgu hacmi (son 60dk) ${expectedPerHour.toFixed(0)}${hourCurrent.total} (${dropPct.toFixed(0)}%, aynı gün/saat ${DOW_BASELINE_WEEKS}hf ort.)`,
severity: dropPct >= 95 ? "critical" : "high", baseline: expectedPerHour,
message: `Sorgu hacmi ${expectedPerWindow.toFixed(0)}${current.total} (${dropPct.toFixed(0)}%, son ${CURRENT_WINDOW_MIN}dk)`, observed: hourCurrent.total,
baseline: expectedPerWindow, current_volume: hourCurrent.total,
observed: current.total, baseline_volume: Math.round(expectedPerHour),
current_volume: current.total, detected_at: ts,
baseline_volume: baseline.total, dedupe_key: `vin:volume_drop:${bucket}`,
detected_at: ts, });
dedupe_key: `vin:volume_drop:${bucket}`,
});
}
} }
// 4. Volume spike: 5x baseline — fraud or viral. One user's retry burst is // 4. Volume spike: 5x baseline — fraud or viral. One user's retry burst is