feat(vin-decode): WMI opportunity radar, per-source p50, unknown-VIN spike alarm

Systematize Faz 4 + close three observability gaps:

- WMI opportunity radar (getWmiOpportunities): group query_logs by
  substring(vin,1,3) = WMI, rank low-success WMIs by distinct-user demand.
  brandMatched=0 flags fully-uncovered manufacturer codes. Surfaced on the
  Trends page (#wmi) + a dashboard header shortcut. Replaces the manual Faz 4.
- Per-source p50: add percentile_cont(0.50) to getProviderAttempts and
  getProviderDeepStats; show P50·P95 on the dashboard and provider drill-down.
- unknown_vin_spike anomaly: track "tanınamadı" rate in the 15min/baseline
  windows, fire when it jumps >=2x baseline (or surges from ~0), and attribute
  the dominant failing source (+aborted count) in the Telegram message. Runs
  in the existing 5min anomaly cron; worker/telegram unchanged (generic type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-25 01:58:26 +03:00
parent f05e0bc808
commit 985592aa73
6 changed files with 266 additions and 13 deletions

View File

@@ -135,6 +135,13 @@ export default async function VinDecodePage({
>
Trends
</Link>
<Link
href="/projects/sase/vin-decode/trends#wmi"
className={buttonVariants({ variant: "outline", size: "sm" })}
title="Sıradaki eklenecek WMI adayları (talebe göre)"
>
WMI fırsatları
</Link>
</div>
</div>
@@ -222,7 +229,7 @@ export default async function VinDecodePage({
label: a.provider,
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(a.provider === "pcat" ? "parts-catalogs" : a.provider === "vin_api" ? "vin-api" : a.provider)}`,
count: a.attemptCount,
meta: `Ø ${ms(a.avgMs)} · P95 ${ms(a.p95Ms)}`,
meta: `P50 ${ms(a.p50Ms)} · P95 ${ms(a.p95Ms)}`,
}))}
/>
</CardContent>

View File

@@ -109,16 +109,16 @@ export default async function ProviderDeepPage({
value={`${(stats.wins.successRate * 100).toFixed(1)}%`}
/>
<Kpi
label="Avg RT"
value={stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"}`}
label="P50 / P95 RT"
value={stats.wins.p50 != null ? `${stats.wins.p50}ms` : "—"}
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"} · Ø ${stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}`}
/>
<Kpi
label="Chain'de görüldü"
value={stats.attempts.total.toLocaleString("tr-TR")}
hint={
stats.attempts.total > 0
? `Ø ${stats.attempts.avgMs}ms · P95 ${stats.attempts.p95 ?? ""}ms`
? `P50 ${stats.attempts.p50 ?? ""}ms · P95 ${stats.attempts.p95 ?? ""}ms`
: "chain key'ı yok"
}
/>

View File

@@ -20,6 +20,7 @@ import {
getWeeklyGrowth,
getProviderShareTrend,
getUnderSupportedBrands,
getWmiOpportunities,
getPeakHeatmap,
getCacheHitTrend,
getEfficiencyTrend,
@@ -42,12 +43,13 @@ const PROVIDER_COLORS: Record<string, string> = {
};
export default async function TrendsPage() {
const [volume, growth, shareTrend, underSupported, heatmap, cache, efficiency] =
const [volume, growth, shareTrend, underSupported, wmiOpps, heatmap, cache, efficiency] =
await Promise.all([
getLongTermVolume(90),
getWeeklyGrowth(12),
getProviderShareTrend(30),
getUnderSupportedBrands(30, 20, 0.75),
getWmiOpportunities(30, 15, 0.7),
getPeakHeatmap(30),
getCacheHitTrend(30),
getEfficiencyTrend(30),
@@ -252,6 +254,82 @@ export default async function TrendsPage() {
</CardContent>
</Card>
{/* 4b. WMI opportunity radar — data-driven Faz 4 */}
<Card id="wmi">
<CardHeader>
<CardDescription>
4b. WMI fırsat radarı (son 30g, hacim 15, başarı 70%) sıradaki
ekleme adayları, talebe göre sıralı
</CardDescription>
</CardHeader>
<CardContent>
{wmiOpps.length === 0 ? (
<p className="text-sm text-muted-foreground">
Düşük başarılı WMI yok talep edilen üretici kodları yeterince
karşılanıyor.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>WMI</TableHead>
<TableHead>Durum / marka</TableHead>
<TableHead className="text-right">Talep (kullanıcı)</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Fail</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wmiOpps.map((w) => (
<TableRow key={w.wmi}>
<TableCell className="font-mono text-xs">{w.wmi}</TableCell>
<TableCell className="text-xs">
{w.brandMatched === 0 ? (
<Badge variant="destructive">eşleşmesiz WMI</Badge>
) : (
<>
<span className="font-mono">{w.topBrandSlug ?? "?"}</span>
{w.topBrandName && (
<span className="ml-1 text-muted-foreground">
{w.topBrandName}
</span>
)}
<span className="ml-1 text-muted-foreground">· decode zayıf</span>
</>
)}
{w.topResultKind && (
<span className="ml-1 text-muted-foreground">
({w.topResultKind})
</span>
)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{w.uniqueUsers}
</TableCell>
<TableCell className="text-right tabular-nums">
{w.total.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{w.failed.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{(w.successRate * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="mt-2 text-xs text-muted-foreground">
&quot;eşleşmesiz WMI&quot; = hiç markaya bağlanamayan üretici kodu
eklenince tamamen yeni kapsama. Talep (farklı kullanıcı) sütununa
göre sıralı: en çok kişinin isteyip alamadığı WMI en üstte. Faz 4&apos;ün
el yordamı yerine veri-güdümlü hali.
</p>
</CardContent>
</Card>
{/* 5. Peak heatmap */}
<Card>
<CardHeader>

View File

@@ -12,7 +12,8 @@ export type AnomalyType =
| "p95_latency_spike"
| "volume_drop"
| "volume_spike"
| "timeout_dominance";
| "timeout_dominance"
| "unknown_vin_spike";
export type AnomalyHit = {
type: AnomalyType;
@@ -36,6 +37,7 @@ type WindowStats = {
p95: number | null;
avgMs: number | null;
timeouts: number;
unknownVins: number;
};
async function windowStats(start: Date, end: Date): Promise<WindowStats> {
@@ -47,6 +49,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
p95: number | null;
avg_ms: number | null;
timeouts: bigint;
unknown_vins: bigint;
}>
>`
SELECT
@@ -63,7 +66,16 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
OR error_message ILIKE '%timeout%'
OR (timings->>'aborted')::boolean = true
)
) AS timeouts
) AS timeouts,
count(*) FILTER (
WHERE success = false
AND (
error_message ILIKE '%unknown vin%'
OR error_message ILIKE '%tanınamad%'
OR error_message ILIKE '%destekl%'
OR (timings->>'result_kind') = 'unknown'
)
) AS unknown_vins
FROM query_logs
WHERE created_at >= ${start} AND created_at < ${end}
`;
@@ -74,6 +86,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
p95: null,
avg_ms: null,
timeouts: 0n,
unknown_vins: 0n,
};
const total = Number(r.total);
const succeeded = Number(r.succeeded);
@@ -87,6 +100,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
p95: r.p95,
avgMs: r.avg_ms,
timeouts: Number(r.timeouts),
unknownVins: Number(r.unknown_vins),
};
}
@@ -114,6 +128,7 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
p95: null,
avgMs: null,
timeouts: 0,
unknownVins: 0,
};
}
const total = samples.reduce((a, b) => a + b.total, 0);
@@ -122,6 +137,7 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
const p95Values = samples.map((s) => s.p95).filter((v): v is number => v != null);
const avgValues = samples.map((s) => s.avgMs).filter((v): v is number => v != null);
const timeouts = samples.reduce((a, b) => a + b.timeouts, 0);
const unknownVins = samples.reduce((a, b) => a + b.unknownVins, 0);
return {
total,
succeeded,
@@ -131,9 +147,37 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
p95: p95Values.length ? Math.round(p95Values.reduce((a, b) => a + b, 0) / p95Values.length) : null,
avgMs: avgValues.length ? Math.round(avgValues.reduce((a, b) => a + b, 0) / avgValues.length) : null,
timeouts,
unknownVins,
};
}
/**
* Attribution for an unknown-VIN / failure spike: which `source` dominates the
* failures in the window, and how many of those were aborted (budget/timeout).
* A proxy/decoder outage shows up here as a surge of source='none'/'aborted'
* or one provider's failures — turns "success dropped" into "EMEX is down".
*/
async function dominantFailingSource(start: Date, end: Date): Promise<string | null> {
const rows = await saseDb.$queryRaw<
Array<{ source: string | null; cnt: bigint; aborted: bigint }>
>`
SELECT
coalesce(source, 'none') AS source,
count(*) AS cnt,
count(*) FILTER (WHERE (timings->>'aborted')::boolean = true) AS aborted
FROM query_logs
WHERE created_at >= ${start} AND created_at < ${end} AND success = false
GROUP BY coalesce(source, 'none')
ORDER BY count(*) DESC
LIMIT 1
`;
const r = rows[0];
if (!r) return null;
const cnt = Number(r.cnt);
const aborted = Number(r.aborted);
return aborted > 0 ? `${r.source} (${cnt}, ${aborted} aborted)` : `${r.source} (${cnt})`;
}
export async function detectVinAnomalies(): Promise<{
current: WindowStats;
baseline: WindowStats;
@@ -243,6 +287,35 @@ export async function detectVinAnomalies(): Promise<{
});
}
// 6. Unknown-VIN spike ("tanınamadı" patlaması) — proxy/decoder outage. The
// generic success-rate-drop catches this too, but this names it explicitly
// and attributes the dominant failing source. Fire when the unknown-VIN
// share jumps ≥2× baseline, or surges from a near-zero baseline.
if (current.total >= MIN_CURRENT_VOLUME) {
const curRate = current.unknownVins / current.total;
const baseRate = baseline.total > 0 ? baseline.unknownVins / baseline.total : 0;
const spiked =
curRate >= 0.15 &&
(baseRate < 0.02 ? current.unknownVins >= 5 : curRate >= baseRate * 2);
if (spiked) {
const attribution = await dominantFailingSource(currentStart, now);
hits.push({
type: "unknown_vin_spike",
severity: curRate >= 0.4 ? "critical" : "high",
message:
`"Tanınamadı" oranı ${pct(baseRate)}${pct(curRate)} ` +
`(${current.unknownVins}/${current.total}, son ${CURRENT_WINDOW_MIN}dk)` +
(attribution ? ` · baskın source: ${attribution}` : ""),
baseline: baseRate,
observed: curRate,
current_volume: current.total,
baseline_volume: baseline.total,
detected_at: ts,
dedupe_key: `vin:unknown_vin_spike:${bucket}`,
});
}
}
return { current, baseline, anomalies: hits };
}

View File

@@ -166,6 +166,7 @@ export type ProviderAttemptRow = {
provider: string;
attemptCount: number;
avgMs: number;
p50Ms: number | null;
p95Ms: number | null;
};
@@ -175,12 +176,19 @@ export async function getProviderAttempts(range: TimeRange): Promise<ProviderAtt
const start = rangeStart(range);
const rows = await saseDb.$queryRaw<
Array<{ provider: string; cnt: bigint; avg_ms: number | null; p95_ms: number | null }>
Array<{
provider: string;
cnt: bigint;
avg_ms: number | null;
p50_ms: number | null;
p95_ms: number | null;
}>
>`
SELECT
key AS provider,
count(*) AS cnt,
avg((value::text)::numeric)::int AS avg_ms,
percentile_cont(0.50) WITHIN GROUP (ORDER BY (value::text)::numeric)::int AS p50_ms,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (value::text)::numeric)::int AS p95_ms
FROM query_logs, jsonb_each(timings) AS j(key, value)
WHERE created_at >= ${start}
@@ -194,6 +202,7 @@ export async function getProviderAttempts(range: TimeRange): Promise<ProviderAtt
provider: r.provider,
attemptCount: Number(r.cnt),
avgMs: r.avg_ms ?? 0,
p50Ms: r.p50_ms,
p95Ms: r.p95_ms,
}));
}

View File

@@ -164,6 +164,87 @@ export async function getUnderSupportedBrands(
});
}
// ─── WMI opportunity radar — systematizes "which WMI to add next" (Faz 4) ──
// WMI = World Manufacturer Identifier = first 3 chars of the VIN (ISO 3779).
// Grouping by substring(vin,1,3) works on 100% of historical rows regardless
// of whether timings.wmi has been backfilled. We surface WMIs with meaningful
// demand but low decode success — ranked by *distinct users* (real demand,
// not one user retrying). `brandMatched = 0` means the WMI never resolved to
// any brand at all → a fully-uncovered manufacturer code (strongest "add this"
// signal). result_kind (when present in timings) labels the dominant outcome.
export type WmiOpportunity = {
wmi: string;
total: number;
succeeded: number;
failed: number;
successRate: number;
uniqueUsers: number;
brandMatched: number; // how many of these requests carried a brand_id
topBrandSlug: string | null;
topBrandName: string | null;
topResultKind: string | null;
};
export async function getWmiOpportunities(
days = 30,
minVolume = 15,
maxSuccessRate = 0.7,
): Promise<WmiOpportunity[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{
wmi: string;
total: bigint;
succeeded: bigint;
failed: bigint;
users: bigint;
brand_matched: bigint;
top_brand_slug: string | null;
top_brand_name: string | null;
top_result_kind: string | null;
}>
>`
SELECT
substring(q.vin from 1 for 3) AS wmi,
count(*) AS total,
count(*) FILTER (WHERE q.success = true) AS succeeded,
count(*) FILTER (WHERE q.success = false) AS failed,
count(DISTINCT q.user_id) AS users,
count(*) FILTER (WHERE q.brand_id IS NOT NULL) AS brand_matched,
mode() WITHIN GROUP (ORDER BY b.slug)
FILTER (WHERE b.slug IS NOT NULL) AS top_brand_slug,
mode() WITHIN GROUP (ORDER BY b.name)
FILTER (WHERE b.name IS NOT NULL) AS top_brand_name,
mode() WITHIN GROUP (ORDER BY (q.timings->>'result_kind'))
FILTER (WHERE (q.timings->>'result_kind') IS NOT NULL) AS top_result_kind
FROM query_logs q
LEFT JOIN brands b ON b.id = q.brand_id
WHERE q.created_at >= ${start}
AND char_length(q.vin) >= 3
GROUP BY substring(q.vin from 1 for 3)
HAVING count(*) >= ${minVolume}
AND count(*) FILTER (WHERE q.success = true)::float / count(*) <= ${maxSuccessRate}
ORDER BY count(DISTINCT q.user_id) DESC, count(*) FILTER (WHERE q.success = false) DESC
LIMIT 30
`;
return rows.map((r) => {
const total = Number(r.total);
const succeeded = Number(r.succeeded);
return {
wmi: r.wmi,
total,
succeeded,
failed: Number(r.failed),
successRate: total > 0 ? succeeded / total : 0,
uniqueUsers: Number(r.users),
brandMatched: Number(r.brand_matched),
topBrandSlug: r.top_brand_slug,
topBrandName: r.top_brand_name,
topResultKind: r.top_result_kind,
};
});
}
// ─── Peak heatmap (hour-of-day × day-of-week, last 30d) ───────────────────
export type HeatmapCell = {
dayOfWeek: number; // 0 = Mon, 6 = Sun (ISO style)
@@ -318,9 +399,9 @@ export type ProviderDeepStats = {
provider: string;
range: "7d" | "30d";
// Won-by-this-provider window
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p95: number | null };
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p50: number | null; p95: number | null };
// Attempted-in-chain window (timings key present)
attempts: { total: number; avgMs: number; p95: number | null };
attempts: { total: number; avgMs: number; p50: number | null; p95: number | null };
// Daily win volume for the window
dailyVolume: Array<{ date: string; count: number }>;
topBrands: Array<{ brandSlug: string | null; brandName: string | null; count: number }>;
@@ -360,6 +441,7 @@ export async function getProviderDeepStats(
total: bigint;
succeeded: bigint;
avg_ms: number | null;
p50: number | null;
p95: number | null;
}>
>`
@@ -367,6 +449,7 @@ export async function getProviderDeepStats(
count(*) AS total,
count(*) FILTER (WHERE success = true) AS succeeded,
avg(response_time_ms)::int AS avg_ms,
percentile_cont(0.50) WITHIN GROUP (ORDER BY response_time_ms)::int AS p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)::int AS p95
FROM query_logs
WHERE created_at >= ${start} AND source = ${provider}
@@ -381,14 +464,15 @@ export async function getProviderDeepStats(
// Attempts: timings key present (provider showed up in the chain even if it
// didn't win). Some providers have no timings key (e.g. 'cache'), so this
// can be empty.
let attempts = { total: 0, avgMs: 0, p95: null as number | null };
let attempts = { total: 0, avgMs: 0, p50: null as number | null, p95: null as number | null };
if (timingsKey) {
const [attemptsRow] = await saseDb.$queryRaw<
Array<{ total: bigint; avg_ms: number | null; p95_ms: number | null }>
Array<{ total: bigint; avg_ms: number | null; p50_ms: number | null; p95_ms: number | null }>
>`
SELECT
count(*) AS total,
avg((timings->>${timingsKey})::numeric)::int AS avg_ms,
percentile_cont(0.50) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p50_ms,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p95_ms
FROM query_logs
WHERE created_at >= ${start}
@@ -399,6 +483,7 @@ export async function getProviderDeepStats(
attempts = {
total: Number(attemptsRow.total),
avgMs: attemptsRow.avg_ms ?? 0,
p50: attemptsRow.p50_ms,
p95: attemptsRow.p95_ms,
};
}
@@ -514,6 +599,7 @@ export async function getProviderDeepStats(
succeeded: winsSucceeded,
successRate: winsTotal > 0 ? winsSucceeded / winsTotal : 0,
avgMs: winsRow?.avg_ms ?? null,
p50: winsRow?.p50 ?? null,
p95: winsRow?.p95 ?? null,
},
attempts,