feat(sase): VIN Decode Observability dashboard — Phase 8 Faz 1
Süper Panel VIN Decode Observability Module (SP-VIN-001), Faz 1 MVP.
Reads directly from Sase.tr's query_logs via DATABASE_URL_SASE_RO —
no PostHog hop, no ingestion worker, no aggregation tables. Sase.tr's
existing query_logs.timings jsonb already captures the full chain
context (per-provider durations, cache source, circuit-breaker state).
Repo (apps/web/src/lib/sase/vin-decode.ts)
- getOperationalHealth(range) — total/success/failure counts, success
& error rates, P50/P95/P99 + avg response time, cache hit rate,
timeout rate, unknown-VIN rate. Single raw query with FILTER aggs.
- getWinningProviders(range) — group by source (decoded by), share +
avg response time per provider.
- getProviderAttempts(range) — group by timings jsonb keys
(pcat/emex/pl24/vin_api). A row may contribute to multiple providers
if the chain ran through several.
- getFallbackChains(range) — multi-provider rows grouped into chain
strings (e.g. 'pcat→emex→pl24') with frequency.
- getErrorBreakdown(range) — bucket errors via regex
(BUDGET_EXCEEDED, UNKNOWN_VIN, TIMEOUT, …) on errorMessage.
- getDailyTrend(days) — date_trunc('day') volume + success + failure
+ avg latency. Filled with zero-rows so the sparkline is continuous.
- getBrandBreakdown(range, limit) — top brands joined to brands.name/slug
with per-brand success rate.
Dashboard (/projects/sase/vin-decode)
- 4-button time range picker (1h/24h/7d/30d) via URL search-params.
- 6 KPI cards (Success, Error, P95 RT, Cache hit, Timeout, Unknown VIN)
color-coded by threshold.
- Two parallel provider views: "winning provider" (decode resolver)
vs "provider attempt" (timings keys — wider, includes fallthroughs).
- Fallback chain frequency table.
- Error bucket cards with example messages.
- Top brands by query volume with success rate cells (green/red).
- 30-day daily-volume sparkline with success/total ratio shading.
Sase landing gets a "VIN Decode →" link next to the Users link.
No new tables, no migrations, no workers. RO-only by design.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,12 @@ export async function SaseHealth() {
|
||||
>
|
||||
Users →
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/sase/vin-decode"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
VIN Decode →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
403
apps/web/src/app/projects/sase/vin-decode/page.tsx
Normal file
403
apps/web/src/app/projects/sase/vin-decode/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import Link from "next/link";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
ALL_RANGES,
|
||||
type TimeRange,
|
||||
getOperationalHealth,
|
||||
getWinningProviders,
|
||||
getProviderAttempts,
|
||||
getFallbackChains,
|
||||
getErrorBreakdown,
|
||||
getDailyTrend,
|
||||
getBrandBreakdown,
|
||||
} from "@/lib/sase/vin-decode";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const RANGE_LABEL: Record<TimeRange, string> = {
|
||||
"1h": "Son 1 saat",
|
||||
"24h": "Son 24 saat",
|
||||
"7d": "Son 7 gün",
|
||||
"30d": "Son 30 gün",
|
||||
};
|
||||
|
||||
function isRange(v: string | undefined): v is TimeRange {
|
||||
return v === "1h" || v === "24h" || v === "7d" || v === "30d";
|
||||
}
|
||||
|
||||
export default async function VinDecodePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
|
||||
|
||||
const [health, winning, attempts, chains, errors, trend, brands] = await Promise.all([
|
||||
getOperationalHealth(range),
|
||||
getWinningProviders(range),
|
||||
getProviderAttempts(range),
|
||||
getFallbackChains(range),
|
||||
getErrorBreakdown(range),
|
||||
getDailyTrend(30),
|
||||
getBrandBreakdown(range, 12),
|
||||
]);
|
||||
|
||||
const trendMax = Math.max(1, ...trend.map((t) => t.total));
|
||||
|
||||
return (
|
||||
<PanelShell title="Sase · VIN Decode Observability">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link href="/projects/sase" className="hover:underline">
|
||||
← Sase
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">VIN Decode Observability</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{RANGE_LABEL[range]} · {health.totalCount.toLocaleString("tr-TR")} sorgu
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{ALL_RANGES.map((r) => (
|
||||
<Link
|
||||
key={r}
|
||||
href={`?range=${r}`}
|
||||
className={buttonVariants({
|
||||
variant: r === range ? "default" : "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
scroll={false}
|
||||
>
|
||||
{r}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Kpi
|
||||
label="Başarı"
|
||||
value={pct(health.successRate)}
|
||||
hint={`${health.successCount.toLocaleString("tr-TR")} ok`}
|
||||
tone={
|
||||
health.successRate >= 0.9
|
||||
? "ok"
|
||||
: health.successRate >= 0.75
|
||||
? "warn"
|
||||
: "bad"
|
||||
}
|
||||
/>
|
||||
<Kpi
|
||||
label="Hata"
|
||||
value={pct(health.errorRate)}
|
||||
hint={`${health.failureCount.toLocaleString("tr-TR")} fail`}
|
||||
tone={
|
||||
health.errorRate <= 0.05
|
||||
? "ok"
|
||||
: health.errorRate <= 0.15
|
||||
? "warn"
|
||||
: "bad"
|
||||
}
|
||||
/>
|
||||
<Kpi
|
||||
label="P95 yanıt"
|
||||
value={ms(health.p95ResponseMs)}
|
||||
hint={`P50 ${ms(health.p50ResponseMs)} · P99 ${ms(health.p99ResponseMs)}`}
|
||||
tone={
|
||||
!health.p95ResponseMs
|
||||
? undefined
|
||||
: health.p95ResponseMs <= 3000
|
||||
? "ok"
|
||||
: health.p95ResponseMs <= 8000
|
||||
? "warn"
|
||||
: "bad"
|
||||
}
|
||||
/>
|
||||
<Kpi
|
||||
label="Cache hit"
|
||||
value={pct(health.cacheHitRate)}
|
||||
hint={`${health.cacheHitCount.toLocaleString("tr-TR")}`}
|
||||
/>
|
||||
<Kpi
|
||||
label="Timeout"
|
||||
value={pct(health.timeoutRate)}
|
||||
hint={`${health.timeoutCount.toLocaleString("tr-TR")}`}
|
||||
tone={health.timeoutRate <= 0.05 ? "ok" : "warn"}
|
||||
/>
|
||||
<Kpi
|
||||
label="Unknown VIN"
|
||||
value={pct(health.unknownVinRate)}
|
||||
hint={`${health.unknownVinCount.toLocaleString("tr-TR")}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Kazanan provider (decode'u sonuçlandıran)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DistList
|
||||
items={winning.map((w) => ({
|
||||
label: w.provider,
|
||||
count: w.successCount,
|
||||
meta: `Ø ${ms(w.avgResponseMs)} · %${(w.share * 100).toFixed(1)}`,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Provider deneme (timings'ten — chain'de yer alan)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DistList
|
||||
items={attempts.map((a) => ({
|
||||
label: a.provider,
|
||||
count: a.attemptCount,
|
||||
meta: `Ø ${ms(a.avgMs)} · P95 ${ms(a.p95Ms)}`,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Fallback zincirleri (timings keys)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chains.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Bu pencerede provider chain kullanılmamış.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Chain</TableHead>
|
||||
<TableHead className="text-right">Adet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{chains.map((c) => (
|
||||
<TableRow key={c.chain}>
|
||||
<TableCell className="font-mono text-xs">{c.chain}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{c.occurrences.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Hata kodu breakdown</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Hata yok.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{errors.map((e) => (
|
||||
<li
|
||||
key={e.errorKey}
|
||||
className="rounded-md border p-2"
|
||||
title={e.exampleMessage}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{e.errorKey}
|
||||
</Badge>
|
||||
<span className="tabular-nums">
|
||||
{e.count.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{e.exampleMessage}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>En çok sorgulanan markalar (success rate'li)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{brands.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Marka eşleşmiş sorgu yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Marka</TableHead>
|
||||
<TableHead className="text-right">Sorgu</TableHead>
|
||||
<TableHead className="text-right">Başarı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{brands.map((b) => (
|
||||
<TableRow key={b.brandSlug ?? "unknown"}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{b.brandSlug ?? "?"}</span>
|
||||
{b.brandName && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{b.brandName}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{b.total.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={
|
||||
"text-right tabular-nums " +
|
||||
(b.successRate >= 0.9
|
||||
? "text-emerald-600"
|
||||
: b.successRate >= 0.75
|
||||
? ""
|
||||
: "text-destructive")
|
||||
}
|
||||
>
|
||||
{pct(b.successRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Son 30 gün — günlük sorgu hacmi</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-24 items-end gap-px">
|
||||
{trend.map((t) => (
|
||||
<div
|
||||
key={t.date}
|
||||
title={`${t.date}: ${t.total} (${t.succeeded} ok / ${t.failed} fail · Ø ${ms(t.avgResponseMs)})`}
|
||||
style={{ height: `${(t.total / trendMax) * 100}%` }}
|
||||
className="relative flex-1 min-w-[2px] overflow-hidden rounded-sm bg-muted"
|
||||
>
|
||||
{t.succeeded > 0 && (
|
||||
<div
|
||||
style={{ height: `${(t.succeeded / t.total) * 100}%` }}
|
||||
className="absolute bottom-0 left-0 right-0 bg-primary/70"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>{trend[0]?.date}</span>
|
||||
<span>maks {trendMax}/gün</span>
|
||||
<span>{trend[trend.length - 1]?.date}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
tone?: "ok" | "warn" | "bad";
|
||||
}) {
|
||||
const toneColor =
|
||||
tone === "ok"
|
||||
? "text-emerald-600"
|
||||
: tone === "warn"
|
||||
? "text-yellow-600"
|
||||
: tone === "bad"
|
||||
? "text-destructive"
|
||||
: "";
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className={`text-2xl font-semibold tabular-nums ${toneColor}`}>
|
||||
{value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{hint && (
|
||||
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DistList({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; count: number; meta?: string }>;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Veri yok.</p>;
|
||||
}
|
||||
const max = Math.max(1, ...items.map((i) => i.count));
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((i) => (
|
||||
<li key={i.label} className="space-y-1 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-mono text-xs">{i.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{i.meta}</span>
|
||||
<span className="w-16 text-right tabular-nums">{i.count.toLocaleString("tr-TR")}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded bg-muted">
|
||||
<div className="h-full bg-primary" style={{ width: `${(i.count / max) * 100}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function ms(v: number | null): string {
|
||||
if (v == null) return "—";
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(1)}s`;
|
||||
return `${v}ms`;
|
||||
}
|
||||
390
apps/web/src/lib/sase/vin-decode.ts
Normal file
390
apps/web/src/lib/sase/vin-decode.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
|
||||
export type TimeRange = "1h" | "24h" | "7d" | "30d";
|
||||
|
||||
const RANGE_MS: Record<TimeRange, number> = {
|
||||
"1h": 60 * 60_000,
|
||||
"24h": 24 * 60 * 60_000,
|
||||
"7d": 7 * 24 * 60 * 60_000,
|
||||
"30d": 30 * 24 * 60 * 60_000,
|
||||
};
|
||||
|
||||
export const ALL_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d"];
|
||||
|
||||
function rangeStart(range: TimeRange): Date {
|
||||
return new Date(Date.now() - RANGE_MS[range]);
|
||||
}
|
||||
|
||||
// ─── Operasyonel sağlık ───────────────────────────────────────────────────
|
||||
export type OperationalHealth = {
|
||||
range: TimeRange;
|
||||
rangeStart: Date;
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
errorRate: number;
|
||||
cacheHitCount: number;
|
||||
cacheHitRate: number;
|
||||
timeoutCount: number;
|
||||
timeoutRate: number;
|
||||
unknownVinCount: number;
|
||||
unknownVinRate: number;
|
||||
p50ResponseMs: number | null;
|
||||
p95ResponseMs: number | null;
|
||||
p99ResponseMs: number | null;
|
||||
avgResponseMs: number | null;
|
||||
};
|
||||
|
||||
export async function getOperationalHealth(range: TimeRange): Promise<OperationalHealth> {
|
||||
const start = rangeStart(range);
|
||||
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
total: bigint;
|
||||
succeeded: bigint;
|
||||
failed: bigint;
|
||||
cache_hits: bigint;
|
||||
timeouts: bigint;
|
||||
unknown_vins: bigint;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
p99: number | null;
|
||||
avg_ms: number | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE success = true) AS succeeded,
|
||||
count(*) FILTER (WHERE success = false) AS failed,
|
||||
count(*) FILTER (
|
||||
WHERE source = 'cache'
|
||||
OR (timings->>'cache_source') IN ('db_hit', 'redis_positive')
|
||||
) AS cache_hits,
|
||||
count(*) FILTER (
|
||||
WHERE success = false
|
||||
AND (
|
||||
error_message ILIKE '%budget%'
|
||||
OR error_message ILIKE '%timeout%'
|
||||
OR (timings->>'aborted')::boolean = true
|
||||
)
|
||||
) AS timeouts,
|
||||
count(*) FILTER (
|
||||
WHERE success = false
|
||||
AND (error_message ILIKE '%Unknown VIN%' OR error_message ILIKE '%tanınamadı%')
|
||||
) AS unknown_vins,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY response_time_ms)
|
||||
FILTER (WHERE success = true AND response_time_ms IS NOT NULL)::int AS p50,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)
|
||||
FILTER (WHERE success = true AND response_time_ms IS NOT NULL)::int AS p95,
|
||||
percentile_cont(0.99) WITHIN GROUP (ORDER BY response_time_ms)
|
||||
FILTER (WHERE success = true AND response_time_ms IS NOT NULL)::int AS p99,
|
||||
avg(response_time_ms) FILTER (WHERE success = true AND response_time_ms IS NOT NULL)::int AS avg_ms
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start}
|
||||
`;
|
||||
|
||||
const r = rows[0] ?? {
|
||||
total: 0n,
|
||||
succeeded: 0n,
|
||||
failed: 0n,
|
||||
cache_hits: 0n,
|
||||
timeouts: 0n,
|
||||
unknown_vins: 0n,
|
||||
p50: null,
|
||||
p95: null,
|
||||
p99: null,
|
||||
avg_ms: null,
|
||||
};
|
||||
|
||||
const total = Number(r.total);
|
||||
const succeeded = Number(r.succeeded);
|
||||
const failed = Number(r.failed);
|
||||
const cacheHits = Number(r.cache_hits);
|
||||
const timeouts = Number(r.timeouts);
|
||||
const unknownVins = Number(r.unknown_vins);
|
||||
|
||||
return {
|
||||
range,
|
||||
rangeStart: start,
|
||||
totalCount: total,
|
||||
successCount: succeeded,
|
||||
failureCount: failed,
|
||||
successRate: total > 0 ? succeeded / total : 0,
|
||||
errorRate: total > 0 ? failed / total : 0,
|
||||
cacheHitCount: cacheHits,
|
||||
cacheHitRate: total > 0 ? cacheHits / total : 0,
|
||||
timeoutCount: timeouts,
|
||||
timeoutRate: total > 0 ? timeouts / total : 0,
|
||||
unknownVinCount: unknownVins,
|
||||
unknownVinRate: total > 0 ? unknownVins / total : 0,
|
||||
p50ResponseMs: r.p50,
|
||||
p95ResponseMs: r.p95,
|
||||
p99ResponseMs: r.p99,
|
||||
avgResponseMs: r.avg_ms,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Provider performansı ─────────────────────────────────────────────────
|
||||
export type WinningProviderRow = {
|
||||
provider: string;
|
||||
successCount: number;
|
||||
avgResponseMs: number | null;
|
||||
share: number;
|
||||
};
|
||||
|
||||
export async function getWinningProviders(range: TimeRange): Promise<WinningProviderRow[]> {
|
||||
const start = rangeStart(range);
|
||||
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{ provider: string; cnt: bigint; avg_ms: number | null; total_success: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
coalesce(source, 'unknown') AS provider,
|
||||
count(*) AS cnt,
|
||||
avg(response_time_ms)::int AS avg_ms,
|
||||
(SELECT count(*) FROM query_logs WHERE created_at >= ${start} AND success = true) AS total_success
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start} AND success = true
|
||||
GROUP BY source
|
||||
ORDER BY count(*) DESC
|
||||
`;
|
||||
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.total_success);
|
||||
const cnt = Number(r.cnt);
|
||||
return {
|
||||
provider: r.provider,
|
||||
successCount: cnt,
|
||||
avgResponseMs: r.avg_ms,
|
||||
share: total > 0 ? cnt / total : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ProviderAttemptRow = {
|
||||
provider: string;
|
||||
attemptCount: number;
|
||||
avgMs: number;
|
||||
p95Ms: number | null;
|
||||
};
|
||||
|
||||
// Per-provider attempts pulled from timings jsonb keys (pcat, emex, pl24, vin_api)
|
||||
// — a row may contain multiple keys (chain), each one counted as an attempt.
|
||||
export async function getProviderAttempts(range: TimeRange): Promise<ProviderAttemptRow[]> {
|
||||
const start = rangeStart(range);
|
||||
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{ provider: string; cnt: bigint; avg_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.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}
|
||||
AND key IN ('pcat', 'emex', 'pl24', 'vin_api')
|
||||
AND jsonb_typeof(value) = 'number'
|
||||
GROUP BY key
|
||||
ORDER BY count(*) DESC
|
||||
`;
|
||||
|
||||
return rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
attemptCount: Number(r.cnt),
|
||||
avgMs: r.avg_ms ?? 0,
|
||||
p95Ms: r.p95_ms,
|
||||
}));
|
||||
}
|
||||
|
||||
export type FallbackChainRow = {
|
||||
chain: string;
|
||||
occurrences: number;
|
||||
};
|
||||
|
||||
// Detect fallback chains: rows whose `timings` jsonb contains more than one
|
||||
// provider key (e.g. {pcat, emex} or {pcat, emex, pl24}) — order encodes the
|
||||
// chain (pcat → emex → pl24 → vin_api per the resolveVin order in Sase.tr).
|
||||
export async function getFallbackChains(range: TimeRange): Promise<FallbackChainRow[]> {
|
||||
const start = rangeStart(range);
|
||||
|
||||
const rows = await saseDb.$queryRaw<Array<{ chain: string; cnt: bigint }>>`
|
||||
WITH provider_keys AS (
|
||||
SELECT
|
||||
id,
|
||||
(timings ? 'pcat')::int AS pcat,
|
||||
(timings ? 'emex')::int AS emex,
|
||||
(timings ? 'pl24')::int AS pl24,
|
||||
(timings ? 'vin_api')::int AS vin_api
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start}
|
||||
),
|
||||
chains AS (
|
||||
SELECT
|
||||
concat_ws(
|
||||
'→',
|
||||
CASE WHEN pcat = 1 THEN 'pcat' END,
|
||||
CASE WHEN emex = 1 THEN 'emex' END,
|
||||
CASE WHEN pl24 = 1 THEN 'pl24' END,
|
||||
CASE WHEN vin_api = 1 THEN 'vin_api' END
|
||||
) AS chain
|
||||
FROM provider_keys
|
||||
WHERE pcat + emex + pl24 + vin_api > 0
|
||||
)
|
||||
SELECT chain, count(*) AS cnt
|
||||
FROM chains
|
||||
GROUP BY chain
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
return rows.map((r) => ({ chain: r.chain, occurrences: Number(r.cnt) }));
|
||||
}
|
||||
|
||||
// ─── Hata breakdown ───────────────────────────────────────────────────────
|
||||
export type ErrorBucketRow = {
|
||||
errorKey: string;
|
||||
count: number;
|
||||
exampleMessage: string;
|
||||
};
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: string; matchers: RegExp[] }> = [
|
||||
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
|
||||
{ key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] },
|
||||
{ key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] },
|
||||
{ key: "INVALID_VIN", matchers: [/geçersiz/i, /invalid vin/i] },
|
||||
{ key: "FORBIDDEN", matchers: [/yetersiz/i, /forbidden/i, /access/i] },
|
||||
{ key: "RATE_LIMITED", matchers: [/rate limit/i, /429/] },
|
||||
{ key: "PROVIDER_ERROR", matchers: [/pl24/i, /emex/i, /partscatalogs/i, /pcat/i] },
|
||||
];
|
||||
|
||||
function bucketError(msg: string | null): string {
|
||||
if (!msg) return "OTHER";
|
||||
for (const { key, matchers } of ERROR_PATTERNS) {
|
||||
if (matchers.some((m) => m.test(msg))) return key;
|
||||
}
|
||||
return "OTHER";
|
||||
}
|
||||
|
||||
export async function getErrorBreakdown(range: TimeRange): Promise<ErrorBucketRow[]> {
|
||||
const start = rangeStart(range);
|
||||
const errors = await saseDb.queryLog.findMany({
|
||||
where: { createdAt: { gte: start }, success: false },
|
||||
select: { errorMessage: true },
|
||||
take: 5000,
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { count: number; example: string }>();
|
||||
for (const e of errors) {
|
||||
const key = bucketError(e.errorMessage);
|
||||
const b = buckets.get(key);
|
||||
if (b) b.count++;
|
||||
else buckets.set(key, { count: 1, example: e.errorMessage?.slice(0, 200) ?? "" });
|
||||
}
|
||||
return Array.from(buckets.entries())
|
||||
.map(([errorKey, { count, example }]) => ({ errorKey, count, exampleMessage: example }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
// ─── Daily trend (uzun dönem trend için sabit 30g) ────────────────────────
|
||||
export type DailyTrendPoint = {
|
||||
date: string;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
avgResponseMs: number | null;
|
||||
};
|
||||
|
||||
export async function getDailyTrend(days = 30): Promise<DailyTrendPoint[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
day: Date;
|
||||
total: bigint;
|
||||
succeeded: bigint;
|
||||
failed: bigint;
|
||||
avg_ms: number | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
date_trunc('day', created_at) AS day,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE success = true) AS succeeded,
|
||||
count(*) FILTER (WHERE success = false) AS failed,
|
||||
avg(response_time_ms) FILTER (WHERE success = true)::int AS avg_ms
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start}
|
||||
GROUP BY date_trunc('day', created_at)
|
||||
ORDER BY day ASC
|
||||
`;
|
||||
|
||||
// Fill missing days with zeros so the sparkline is continuous.
|
||||
const filled: DailyTrendPoint[] = [];
|
||||
const today = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today.getTime() - i * 24 * 60 * 60_000);
|
||||
const dayKey = d.toISOString().slice(0, 10);
|
||||
const found = rows.find((r) => r.day.toISOString().slice(0, 10) === dayKey);
|
||||
filled.push({
|
||||
date: dayKey,
|
||||
total: found ? Number(found.total) : 0,
|
||||
succeeded: found ? Number(found.succeeded) : 0,
|
||||
failed: found ? Number(found.failed) : 0,
|
||||
avgResponseMs: found?.avg_ms ?? null,
|
||||
});
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
// ─── Brand dağılımı (Sase.tr brand normalization) ─────────────────────────
|
||||
export type BrandRow = {
|
||||
brandSlug: string | null;
|
||||
brandName: string | null;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
};
|
||||
|
||||
export async function getBrandBreakdown(range: TimeRange, limit = 20): Promise<BrandRow[]> {
|
||||
const start = rangeStart(range);
|
||||
// Group by brand_id, join brand name/slug, ignore decodes that didn't resolve a brand.
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
brand_id: string | null;
|
||||
brand_name: string | null;
|
||||
brand_slug: string | null;
|
||||
total: bigint;
|
||||
succeeded: bigint;
|
||||
failed: bigint;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
q.brand_id,
|
||||
b.name AS brand_name,
|
||||
b.slug AS brand_slug,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE q.success = true) AS succeeded,
|
||||
count(*) FILTER (WHERE q.success = false) AS failed
|
||||
FROM query_logs q
|
||||
LEFT JOIN brands b ON b.id = q.brand_id
|
||||
WHERE q.created_at >= ${start} AND q.brand_id IS NOT NULL
|
||||
GROUP BY q.brand_id, b.name, b.slug
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.total);
|
||||
const succeeded = Number(r.succeeded);
|
||||
return {
|
||||
brandSlug: r.brand_slug,
|
||||
brandName: r.brand_name,
|
||||
total,
|
||||
succeeded,
|
||||
failed: Number(r.failed),
|
||||
successRate: total > 0 ? succeeded / total : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user