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:
310
apps/web/src/app/projects/sase/vin-decode/errors/[key]/page.tsx
Normal file
310
apps/web/src/app/projects/sase/vin-decode/errors/[key]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
getDailyTrend,
|
||||
getBrandBreakdown,
|
||||
} 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";
|
||||
|
||||
@@ -50,15 +55,20 @@ export default async function VinDecodePage({
|
||||
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 [health, winning, attempts, chains, errors, trend, brands, deploys, insights] =
|
||||
await Promise.all([
|
||||
getOperationalHealth(range),
|
||||
getWinningProviders(range),
|
||||
getProviderAttempts(range),
|
||||
getFallbackChains(range),
|
||||
getErrorBreakdown(range),
|
||||
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));
|
||||
|
||||
@@ -224,22 +234,23 @@ export default async function VinDecodePage({
|
||||
) : (
|
||||
<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 key={e.errorKey} title={e.exampleMessage}>
|
||||
<Link
|
||||
href={`/projects/sase/vin-decode/errors/${e.errorKey}?range=${range}`}
|
||||
className="block rounded-md border p-2 hover:bg-muted/30"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -298,6 +309,146 @@ export default async function VinDecodePage({
|
||||
</Card>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardDescription>Son 30 gün — günlük sorgu hacmi</CardDescription>
|
||||
|
||||
126
apps/web/src/lib/sase/deploy-timeline.ts
Normal file
126
apps/web/src/lib/sase/deploy-timeline.ts
Normal 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;
|
||||
}
|
||||
@@ -249,7 +249,20 @@ export type ErrorBucketRow = {
|
||||
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: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/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] },
|
||||
];
|
||||
|
||||
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";
|
||||
for (const { key, matchers } of ERROR_PATTERNS) {
|
||||
if (matchers.some((m) => m.test(msg))) return key;
|
||||
@@ -267,6 +291,195 @@ function bucketError(msg: string | null): string {
|
||||
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[]> {
|
||||
const start = rangeStart(range);
|
||||
const errors = await saseDb.queryLog.findMany({
|
||||
|
||||
85
apps/web/src/lib/sase/vin-insights.ts
Normal file
85
apps/web/src/lib/sase/vin-insights.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user