feat(sase): VIN observability Faz 2 — error drill-down + deploy regression + BIP

Three additions to the VIN Decode Observability dashboard, all read-only.

Faz 2a — Error fingerprint drill-down
- New route /projects/sase/vin-decode/errors/[key] where [key] is one
  of the eight bucket keys (BUDGET_EXCEEDED, UNKNOWN_VIN, TIMEOUT, …).
- getErrorBucketDetail(key, range) returns: total + unique-user count,
  hourly time series, top 10 affected brands (joined to brands.name/
  slug), top providers (winning + chain attempts from timings keys),
  top 20 affected users with link to their detail page, six distinct
  sample error messages, last 30 raw failures (sanitized VIN, source,
  RT, message).
- Error breakdown cards on the main dashboard now link to this page
  with the current time range preserved.
- ERROR_BUCKET_KEYS + ErrorBucketKey exported so the [key] route
  validates against the same list.

Faz 2b — Deploy regression analysis
- listSaseDeploys(limit) fetches the Sase.tr Coolify app's recent
  finished deployments via the Coolify API (COOLIFY_API_TOKEN).
- analyzeDeployRegressions(deploys) slices a 30-minute window
  before deploy.startedAt and after deploy.finishedAt, computes the
  success-rate delta, and flags rows where the drop ≥ 10pp (and
  both windows have ≥ 5 samples).
- Dashboard card lists last ~10 deploys with before/after rates and
  Δ pp, color-coded; "regression" badge on flagged rows.

Faz 2c — BIP integration
- getVinRelatedInsights() reads from the panel-pg `insights` table:
  type='provider_quality' (always VIN-related by prompt-tag routing)
  + bug_triage insights whose body mentions VIN/decode/provider
  keywords. Deduped, severity-then-recency sorted.
- New dashboard card surfaces insight titles with severity + type
  badges, occurrence/user counts, confidence, and a link to the
  Gitea issue if one was opened.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 12:31:27 +03:00
parent 4694c1ffdb
commit 717e4ae369
5 changed files with 912 additions and 27 deletions

View File

@@ -0,0 +1,310 @@
import Link from "next/link";
import { notFound } from "next/navigation";
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,
ERROR_BUCKET_KEYS,
type ErrorBucketKey,
type TimeRange,
getErrorBucketDetail,
} 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";
}
function isBucketKey(v: string): v is ErrorBucketKey {
return (ERROR_BUCKET_KEYS as readonly string[]).includes(v);
}
export default async function ErrorBucketPage({
params,
searchParams,
}: {
params: Promise<{ key: string }>;
searchParams: Promise<{ range?: string }>;
}) {
const { key } = await params;
if (!isBucketKey(key)) notFound();
const sp = await searchParams;
const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
const detail = await getErrorBucketDetail(key, range);
const seriesMax = Math.max(1, ...detail.hourlySeries.map((s) => s.count));
const brandsMax = Math.max(1, ...detail.topBrands.map((b) => b.count));
return (
<PanelShell title={`Sase · VIN errors · ${key}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link href={`/projects/sase/vin-decode?range=${range}`} className="hover:underline">
VIN Decode dashboard
</Link>
</div>
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-xl font-semibold">
<Badge variant="outline" className="mr-2 font-mono">
{key}
</Badge>
error bucket
</h2>
<p className="text-sm text-muted-foreground">
{RANGE_LABEL[range]} · {detail.total.toLocaleString("tr-TR")} fail ·{" "}
{detail.uniqueUsers.toLocaleString("tr-TR")} kullanıcı
</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>
<Card>
<CardHeader>
<CardDescription>Saatlik dağılım</CardDescription>
</CardHeader>
<CardContent>
{detail.hourlySeries.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu pencerede hata yok.</p>
) : (
<>
<div className="flex h-20 items-end gap-px">
{detail.hourlySeries.map((s) => (
<div
key={s.hour}
title={`${s.hour}: ${s.count}`}
style={{ height: `${(s.count / seriesMax) * 100}%` }}
className="flex-1 min-w-[3px] bg-destructive/70 hover:bg-destructive"
/>
))}
</div>
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
<span>{detail.hourlySeries[0]?.hour.slice(0, 16).replace("T", " ")}</span>
<span>maks {seriesMax}/saat</span>
<span>
{detail.hourlySeries[detail.hourlySeries.length - 1]?.hour
.slice(0, 16)
.replace("T", " ")}
</span>
</div>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Etkilenen markalar (top 10)</CardDescription>
</CardHeader>
<CardContent>
{detail.topBrands.length === 0 ? (
<p className="text-sm text-muted-foreground">Marka eşleşmiş hata yok.</p>
) : (
<ul className="space-y-1 text-sm">
{detail.topBrands.map((b) => (
<li
key={b.brandId ?? "none"}
className="flex items-center gap-2"
>
<span className="w-20 truncate font-mono text-xs">
{b.brandSlug ?? "—"}
</span>
<span className="flex-1 truncate text-xs text-muted-foreground">
{b.brandName ?? "(brand-id yok)"}
</span>
<div className="h-2 w-24 overflow-hidden rounded bg-muted">
<div
className="h-full bg-destructive"
style={{ width: `${(b.count / brandsMax) * 100}%` }}
/>
</div>
<span className="w-12 text-right tabular-nums text-xs">
{b.count}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Provider'lar (kazanan + denenen)</CardDescription>
</CardHeader>
<CardContent>
{detail.topProviders.length === 0 ? (
<p className="text-sm text-muted-foreground">Provider bilgisi yok.</p>
) : (
<ul className="space-y-1 text-sm">
{detail.topProviders.map((p) => (
<li key={p.provider} className="flex items-center justify-between">
<Badge variant="outline" className="font-mono">
{p.provider}
</Badge>
<span className="tabular-nums">{p.count.toLocaleString("tr-TR")}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardDescription>Etkilenen kullanıcılar (top 20)</CardDescription>
</CardHeader>
<CardContent>
{detail.affectedUsers.length === 0 ? (
<p className="text-sm text-muted-foreground">Kullanıcı yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>İsim</TableHead>
<TableHead className="text-right">Fail</TableHead>
<TableHead>Son</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.affectedUsers.map((u) => (
<TableRow key={u.userId}>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${u.userId}`}
className="hover:underline"
>
{u.email ?? u.userId.slice(0, 8)}
</Link>
</TableCell>
<TableCell className="text-sm">{u.name ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">
{u.failureCount}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{u.lastFailureAt.toISOString().slice(0, 16).replace("T", " ")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Örnek hata mesajları</CardDescription>
</CardHeader>
<CardContent>
{detail.sampleMessages.length === 0 ? (
<p className="text-sm text-muted-foreground">Mesaj yok.</p>
) : (
<ul className="space-y-1 text-sm">
{detail.sampleMessages.map((m) => (
<li key={m} className="rounded-md border bg-muted/30 p-2 font-mono text-xs">
{m}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>
Son {detail.recent.length} fail
</CardDescription>
</CardHeader>
<CardContent>
{detail.recent.length === 0 ? (
<p className="text-sm text-muted-foreground">Yok.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Kullanıcı</TableHead>
<TableHead>VIN</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">RT</TableHead>
<TableHead>Mesaj</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.recent.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">
{r.createdAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs">
<Link
href={`/projects/sase/users/${r.userId}`}
className="hover:underline"
>
{r.userId.slice(0, 8)}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">{r.vinSanitized}</TableCell>
<TableCell className="font-mono text-xs">{r.source ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{r.responseTimeMs ? `${r.responseTimeMs}ms` : "—"}
</TableCell>
<TableCell
className="max-w-md truncate text-xs text-muted-foreground"
title={r.errorMessage}
>
{r.errorMessage}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</PanelShell>
);
}

View File

@@ -28,6 +28,11 @@ import {
getDailyTrend, getDailyTrend,
getBrandBreakdown, getBrandBreakdown,
} from "@/lib/sase/vin-decode"; } from "@/lib/sase/vin-decode";
import {
listSaseDeploys,
analyzeDeployRegressions,
} from "@/lib/sase/deploy-timeline";
import { getVinRelatedInsights } from "@/lib/sase/vin-insights";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -50,15 +55,20 @@ export default async function VinDecodePage({
const sp = await searchParams; const sp = await searchParams;
const range: TimeRange = isRange(sp.range) ? sp.range : "24h"; const range: TimeRange = isRange(sp.range) ? sp.range : "24h";
const [health, winning, attempts, chains, errors, trend, brands] = await Promise.all([ const [health, winning, attempts, chains, errors, trend, brands, deploys, insights] =
getOperationalHealth(range), await Promise.all([
getWinningProviders(range), getOperationalHealth(range),
getProviderAttempts(range), getWinningProviders(range),
getFallbackChains(range), getProviderAttempts(range),
getErrorBreakdown(range), getFallbackChains(range),
getDailyTrend(30), getErrorBreakdown(range),
getBrandBreakdown(range, 12), getDailyTrend(30),
]); getBrandBreakdown(range, 12),
listSaseDeploys(10),
getVinRelatedInsights(8),
]);
const regressions = deploys.length > 0 ? await analyzeDeployRegressions(deploys) : [];
const trendMax = Math.max(1, ...trend.map((t) => t.total)); const trendMax = Math.max(1, ...trend.map((t) => t.total));
@@ -224,22 +234,23 @@ export default async function VinDecodePage({
) : ( ) : (
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
{errors.map((e) => ( {errors.map((e) => (
<li <li key={e.errorKey} title={e.exampleMessage}>
key={e.errorKey} <Link
className="rounded-md border p-2" href={`/projects/sase/vin-decode/errors/${e.errorKey}?range=${range}`}
title={e.exampleMessage} className="block rounded-md border p-2 hover:bg-muted/30"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge variant="outline" className="font-mono"> <Badge variant="outline" className="font-mono">
{e.errorKey} {e.errorKey}
</Badge> </Badge>
<span className="tabular-nums"> <span className="tabular-nums">
{e.count.toLocaleString("tr-TR")} {e.count.toLocaleString("tr-TR")}
</span> </span>
</div> </div>
<p className="mt-1 truncate text-xs text-muted-foreground"> <p className="mt-1 truncate text-xs text-muted-foreground">
{e.exampleMessage} {e.exampleMessage}
</p> </p>
</Link>
</li> </li>
))} ))}
</ul> </ul>
@@ -298,6 +309,146 @@ export default async function VinDecodePage({
</Card> </Card>
</div> </div>
<Card>
<CardHeader>
<CardDescription>
BIP'ten VIN'le ilgili insight'lar (derin kök neden)
</CardDescription>
</CardHeader>
<CardContent>
{insights.length === 0 ? (
<p className="text-sm text-muted-foreground">
VIN-tag'li aktif insight yok.{" "}
<Link href="/insights" className="underline">
/insights
</Link>{" "}
tam listede.
</p>
) : (
<ul className="space-y-2">
{insights.map((i) => (
<li key={i.id} className="rounded-md border p-2 text-sm">
<div className="flex items-baseline gap-2">
<Badge
variant={
i.severity === "P0"
? "destructive"
: i.severity === "P1"
? "default"
: "outline"
}
>
{i.severity}
</Badge>
<Badge variant="outline" className="font-mono text-xs">
{i.type}
</Badge>
<Link
href={`/insights/i/${i.id}`}
className="flex-1 truncate hover:underline"
>
{i.title}
</Link>
{i.giteaIssueUrl && (
<a
href={i.giteaIssueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:underline"
>
#{i.giteaIssueNumber}{" "}
{i.giteaIssueState && `(${i.giteaIssueState})`}
</a>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span>{i.occurrenceCount} occurrence</span>
<span>{i.uniqueUserCount} kullanıcı</span>
{i.confidence != null && <span>conf {i.confidence.toFixed(2)}</span>}
<span>son {i.lastSeenAt.toISOString().slice(0, 16).replace("T", " ")}</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>
Son deploy'lar + regresyon analizi (30dk önce/sonra)
</CardDescription>
</CardHeader>
<CardContent>
{deploys.length === 0 ? (
<p className="text-sm text-muted-foreground">
Coolify deploy bilgisi yok (COOLIFY_API_TOKEN gerekli).
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Commit</TableHead>
<TableHead className="text-right">Önce (success)</TableHead>
<TableHead className="text-right">Sonra (success)</TableHead>
<TableHead className="text-right">Δ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{regressions.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Henüz post-deploy penceresi geçmemiş.
</TableCell>
</TableRow>
)}
{regressions.map((r) => {
const deltaTone =
r.regressed
? "text-destructive font-semibold"
: r.successRateDeltaPp > 5
? "text-emerald-600"
: "";
return (
<TableRow key={r.deploy.deploymentUuid}>
<TableCell className="font-mono text-xs">
{r.deploy.startedAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs">
{r.deploy.commit?.slice(0, 8) ?? "—"}
{r.regressed && (
<Badge variant="destructive" className="ml-2">
regression
</Badge>
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{(r.before.successRate * 100).toFixed(1)}%{" "}
<span className="text-muted-foreground">
(n={r.before.total})
</span>
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{(r.after.successRate * 100).toFixed(1)}%{" "}
<span className="text-muted-foreground">
(n={r.after.total})
</span>
</TableCell>
<TableCell className={`text-right tabular-nums ${deltaTone}`}>
{r.successRateDeltaPp > 0 ? "+" : ""}
{r.successRateDeltaPp.toFixed(1)}pp
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardDescription>Son 30 gün günlük sorgu hacmi</CardDescription> <CardDescription>Son 30 gün günlük sorgu hacmi</CardDescription>

View File

@@ -0,0 +1,126 @@
import { saseDb } from "@/lib/db-sase";
// Sase.tr's Coolify app UUID (sparkling-snake-...). Hard-coded to scope this
// helper; if we ever multi-project, lift to env per-spoke.
const SASE_APP_UUID = "ro48g8ooo0gk4kskog0oo8s8";
const COOLIFY_BASE = process.env.COOLIFY_API_BASE ?? "https://cool.semih.ai/api/v1";
const COOLIFY_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
export type SaseDeploy = {
deploymentUuid: string;
status: string;
commit: string | null;
startedAt: Date;
finishedAt: Date | null;
};
type CoolifyDeployment = {
deployment_uuid: string;
status?: string;
commit?: string | null;
created_at?: string;
updated_at?: string;
};
export async function listSaseDeploys(limit = 10): Promise<SaseDeploy[]> {
if (!COOLIFY_TOKEN) return [];
let res: Response;
try {
res = await fetch(`${COOLIFY_BASE}/deployments/applications/${SASE_APP_UUID}`, {
headers: { Authorization: `Bearer ${COOLIFY_TOKEN}` },
});
} catch {
return [];
}
if (!res.ok) return [];
const data = (await res.json()) as
| CoolifyDeployment[]
| { deployments: CoolifyDeployment[] };
const list = Array.isArray(data) ? data : (data.deployments ?? []);
const finished = list.filter((d) => d.status === "finished" && d.created_at);
return finished
.slice(0, limit)
.map((d) => ({
deploymentUuid: d.deployment_uuid,
status: d.status ?? "?",
commit: d.commit ?? null,
startedAt: new Date(d.created_at!),
finishedAt: d.updated_at ? new Date(d.updated_at) : null,
}))
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
}
export type DeployRegressionRow = {
deploy: SaseDeploy;
before: { total: number; successRate: number; p95: number | null };
after: { total: number; successRate: number; p95: number | null };
successRateDeltaPp: number; // negative = regression
regressed: boolean;
};
const WINDOW_MIN = 30;
async function windowSlice(start: Date, end: Date) {
const rows = await saseDb.$queryRaw<
Array<{
total: bigint;
succeeded: bigint;
p95: number | null;
}>
>`
SELECT
count(*) AS total,
count(*) FILTER (WHERE success = true) AS succeeded,
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
FROM query_logs
WHERE created_at >= ${start} AND created_at < ${end}
`;
const r = rows[0] ?? { total: 0n, succeeded: 0n, p95: null };
const total = Number(r.total);
const succeeded = Number(r.succeeded);
return {
total,
successRate: total > 0 ? succeeded / total : 0,
p95: r.p95,
};
}
export async function analyzeDeployRegressions(
deploys: SaseDeploy[],
regressionDropPp = 10,
): Promise<DeployRegressionRow[]> {
const out: DeployRegressionRow[] = [];
// The "before" window stops at the deploy's start; "after" begins where the
// deploy finished (or 30s after start if finished missing) so the container
// restart isn't counted as the post-deploy state.
for (const d of deploys) {
const finishedAt = d.finishedAt ?? new Date(d.startedAt.getTime() + 30_000);
const beforeEnd = d.startedAt;
const beforeStart = new Date(beforeEnd.getTime() - WINDOW_MIN * 60_000);
const afterStart = finishedAt;
const afterEnd = new Date(afterStart.getTime() + WINDOW_MIN * 60_000);
if (afterEnd > new Date()) {
// post-window hasn't elapsed yet; skip — too early to judge
continue;
}
const [before, after] = await Promise.all([
windowSlice(beforeStart, beforeEnd),
windowSlice(afterStart, afterEnd),
]);
const deltaPp = (after.successRate - before.successRate) * 100;
out.push({
deploy: d,
before,
after,
successRateDeltaPp: deltaPp,
regressed:
before.total >= 5 &&
after.total >= 5 &&
deltaPp <= -regressionDropPp,
});
}
return out;
}

View File

@@ -249,7 +249,20 @@ export type ErrorBucketRow = {
exampleMessage: string; exampleMessage: string;
}; };
const ERROR_PATTERNS: Array<{ key: string; matchers: RegExp[] }> = [ export const ERROR_BUCKET_KEYS = [
"BUDGET_EXCEEDED",
"UNKNOWN_VIN",
"TIMEOUT",
"INVALID_VIN",
"FORBIDDEN",
"RATE_LIMITED",
"PROVIDER_ERROR",
"OTHER",
] as const;
export type ErrorBucketKey = (typeof ERROR_BUCKET_KEYS)[number];
const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] }, { key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
{ key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] }, { key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] },
{ key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] }, { key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] },
@@ -259,7 +272,18 @@ const ERROR_PATTERNS: Array<{ key: string; matchers: RegExp[] }> = [
{ key: "PROVIDER_ERROR", matchers: [/pl24/i, /emex/i, /partscatalogs/i, /pcat/i] }, { key: "PROVIDER_ERROR", matchers: [/pl24/i, /emex/i, /partscatalogs/i, /pcat/i] },
]; ];
function bucketError(msg: string | null): string { // Build a SQL `WHERE` fragment that matches an error bucket. Returns a tagged
// template part for $queryRaw. Each bucket is encoded as a single regex so
// Postgres can apply it once over error_message.
function bucketSqlMatcher(key: ErrorBucketKey): { pattern: string; isOther: boolean } {
const found = ERROR_PATTERNS.find((p) => p.key === key);
if (!found) return { pattern: "", isOther: true };
// join individual patterns with | for a single ILIKE regex
const sources = found.matchers.map((m) => m.source);
return { pattern: sources.join("|"), isOther: false };
}
function bucketError(msg: string | null): ErrorBucketKey {
if (!msg) return "OTHER"; if (!msg) return "OTHER";
for (const { key, matchers } of ERROR_PATTERNS) { for (const { key, matchers } of ERROR_PATTERNS) {
if (matchers.some((m) => m.test(msg))) return key; if (matchers.some((m) => m.test(msg))) return key;
@@ -267,6 +291,195 @@ function bucketError(msg: string | null): string {
return "OTHER"; return "OTHER";
} }
export type ErrorBucketDetail = {
key: ErrorBucketKey;
range: TimeRange;
total: number;
uniqueUsers: number;
hourlySeries: Array<{ hour: string; count: number }>;
topBrands: Array<{ brandId: string | null; brandSlug: string | null; brandName: string | null; count: number }>;
topProviders: Array<{ provider: string; count: number }>;
affectedUsers: Array<{
userId: string;
name: string | null;
email: string | null;
failureCount: number;
lastFailureAt: Date;
}>;
recent: Array<{
id: string;
userId: string;
vinSanitized: string;
brandId: string | null;
source: string | null;
errorMessage: string;
responseTimeMs: number | null;
timings: Record<string, unknown> | null;
createdAt: Date;
}>;
sampleMessages: string[];
};
function sanitizeVin(vin: string): string {
const upper = vin.toUpperCase();
return upper.length >= 11 ? `${upper.slice(0, 11)}******` : `${upper.slice(0, 3)}***`;
}
export async function getErrorBucketDetail(
key: ErrorBucketKey,
range: TimeRange,
recentLimit = 30,
): Promise<ErrorBucketDetail> {
const start = rangeStart(range);
const failed = await saseDb.queryLog.findMany({
where: { createdAt: { gte: start }, success: false },
select: {
id: true,
userId: true,
vin: true,
brandId: true,
source: true,
errorMessage: true,
responseTimeMs: true,
timings: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
take: 10000,
});
const matching = failed.filter((f) => bucketError(f.errorMessage) === key);
// Hourly time series
const hourlyMap = new Map<string, number>();
for (const m of matching) {
const hourKey = new Date(
Math.floor(m.createdAt.getTime() / (60 * 60_000)) * 60 * 60_000,
).toISOString();
hourlyMap.set(hourKey, (hourlyMap.get(hourKey) ?? 0) + 1);
}
const hourlySeries = Array.from(hourlyMap.entries())
.map(([hour, count]) => ({ hour, count }))
.sort((a, b) => a.hour.localeCompare(b.hour));
// Top brands
const brandCounts = new Map<string | null, number>();
for (const m of matching) {
brandCounts.set(m.brandId, (brandCounts.get(m.brandId) ?? 0) + 1);
}
const brandIds = Array.from(brandCounts.keys()).filter(
(id): id is string => !!id,
);
const brandsRows = brandIds.length
? await saseDb.brand.findMany({
where: { id: { in: brandIds } },
select: { id: true, name: true, slug: true },
})
: [];
const brandMap = new Map(brandsRows.map((b) => [b.id, b]));
const topBrands = Array.from(brandCounts.entries())
.map(([brandId, count]) => {
const b = brandId ? brandMap.get(brandId) : null;
return {
brandId,
brandSlug: b?.slug ?? null,
brandName: b?.name ?? null,
count,
};
})
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// Top providers — from timings keys (chain attempts) + source for the row
const providerCounts = new Map<string, number>();
for (const m of matching) {
if (m.source) providerCounts.set(m.source, (providerCounts.get(m.source) ?? 0) + 1);
const t = m.timings as Record<string, unknown> | null;
if (t && typeof t === "object") {
for (const k of ["pcat", "emex", "pl24", "vin_api"]) {
if (typeof t[k] === "number") {
providerCounts.set(k, (providerCounts.get(k) ?? 0) + 1);
}
}
}
}
const topProviders = Array.from(providerCounts.entries())
.map(([provider, count]) => ({ provider, count }))
.sort((a, b) => b.count - a.count);
// Affected users — aggregate user IDs + recency
const userAgg = new Map<string, { count: number; last: Date }>();
for (const m of matching) {
const cur = userAgg.get(m.userId);
if (cur) {
cur.count += 1;
if (m.createdAt > cur.last) cur.last = m.createdAt;
} else {
userAgg.set(m.userId, { count: 1, last: m.createdAt });
}
}
const userIds = Array.from(userAgg.keys());
const userRows = userIds.length
? await saseDb.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
})
: [];
const userMap = new Map(userRows.map((u) => [u.id, u]));
const affectedUsers = Array.from(userAgg.entries())
.map(([userId, { count, last }]) => {
const u = userMap.get(userId);
return {
userId,
name: u?.name ?? null,
email: u?.email ?? null,
failureCount: count,
lastFailureAt: last,
};
})
.sort((a, b) => b.failureCount - a.failureCount)
.slice(0, 20);
// Recent failures
const recent = matching.slice(0, recentLimit).map((m) => ({
id: m.id,
userId: m.userId,
vinSanitized: sanitizeVin(m.vin),
brandId: m.brandId,
source: m.source,
errorMessage: m.errorMessage ?? "",
responseTimeMs: m.responseTimeMs,
timings: m.timings as Record<string, unknown> | null,
createdAt: m.createdAt,
}));
// Sample distinct error messages
const seenMsgs = new Set<string>();
const sampleMessages: string[] = [];
for (const m of matching) {
const msg = m.errorMessage?.slice(0, 240) ?? "";
if (msg && !seenMsgs.has(msg)) {
seenMsgs.add(msg);
sampleMessages.push(msg);
if (sampleMessages.length >= 6) break;
}
}
return {
key,
range,
total: matching.length,
uniqueUsers: userAgg.size,
hourlySeries,
topBrands,
topProviders,
affectedUsers,
recent,
sampleMessages,
};
}
export async function getErrorBreakdown(range: TimeRange): Promise<ErrorBucketRow[]> { export async function getErrorBreakdown(range: TimeRange): Promise<ErrorBucketRow[]> {
const start = rangeStart(range); const start = rangeStart(range);
const errors = await saseDb.queryLog.findMany({ const errors = await saseDb.queryLog.findMany({

View File

@@ -0,0 +1,85 @@
import { prisma } from "@/lib/db";
// Surfaces BIP (Behavioral Insight Pipeline) insights that are relevant to
// the VIN decode flow on the VIN Observability dashboard. The BIP runs in
// the worker; we just read its emitted `insights` rows here.
//
// Heuristic: insights produced by the `provider_quality` prompt tag are the
// VIN-focused ones (session-tag rules route vin_decode_fail_pattern,
// provider_reliability_issue, vin_decode_repeated_failure to that template).
//
// We also pull bug_triage insights whose body mentions VIN/decode/provider,
// as a wider catch — those are the ones the dashboard cares about that
// happen not to hit the provider_quality routing.
export type VinInsight = {
id: string;
title: string;
severity: string;
type: string;
status: string;
fingerprint: string;
occurrenceCount: number;
uniqueUserCount: number;
lastSeenAt: Date;
confidence: number | null;
giteaIssueUrl: string | null;
giteaIssueNumber: number | null;
giteaIssueState: string | null;
};
const VIN_KEYWORDS = ["vin", "decode", "provider", "pl24", "emex", "pcat", "parts-catalogs"];
export async function getVinRelatedInsights(limit = 8): Promise<VinInsight[]> {
// Cheap path: type='provider_quality' is always VIN-related by design.
const direct = await prisma.insight.findMany({
where: {
projectKey: "sase",
type: "provider_quality",
status: { in: ["new", "in_backlog", "investigating", "shipped"] },
},
orderBy: [{ severity: "asc" }, { lastSeenAt: "desc" }],
take: limit,
});
// Widen with bug_triage insights mentioning VIN keywords. Body is jsonb;
// for a cheap MVP scan we pull the most recent N rows and filter in code.
const recent = await prisma.insight.findMany({
where: {
projectKey: "sase",
type: "bug_triage",
status: { in: ["new", "in_backlog", "investigating", "shipped"] },
},
orderBy: { lastSeenAt: "desc" },
take: 30,
});
const widened = recent.filter((r) => {
const blob = JSON.stringify(r.body).toLowerCase();
return VIN_KEYWORDS.some((k) => blob.includes(k));
});
const seen = new Set<string>();
const merged: typeof direct = [];
for (const i of [...direct, ...widened]) {
if (seen.has(i.id)) continue;
seen.add(i.id);
merged.push(i);
if (merged.length >= limit) break;
}
return merged.map((i) => ({
id: i.id,
title: i.title,
severity: i.severity,
type: i.type,
status: i.status,
fingerprint: i.fingerprint,
occurrenceCount: i.occurrenceCount,
uniqueUserCount: i.uniqueUserCount,
lastSeenAt: i.lastSeenAt,
confidence: i.confidence,
giteaIssueUrl: i.githubIssueUrl,
giteaIssueNumber: i.githubIssueNumber,
giteaIssueState: i.githubIssueState,
}));
}