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:
Semih
2026-05-18 12:06:41 +03:00
parent 096ca9b4a3
commit 66a5828ee6
3 changed files with 799 additions and 0 deletions

View File

@@ -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">

View 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`;
}

View 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,
};
});
}