feat(insights): detect parts/catalog coverage failures (client + server)
Surfaces "vehicle resolved but no parts/categories" — Sase's #1 churn signal, previously invisible to insights (a prospect emailed support instead of any insight firing). Client (PostHog session tagger): - fetch the catalog_*/category_view_changed/empty_catalog_cta_clicked event family (was never pulled) and tag parts_render_blocked (P1) / catalog_empty_result (P2), scoped to the real /dashboard product (demo excluded). Harden the search-affordance-misuse guard so a real parts failure is never suppressed. Route both tags to bug_triage. Server (decode-log catalog gaps): - new catalog-gap-detect worker job (every 6h) -> panel internal API /api/internal/catalog-gap-check aggregates "No catalog - identified as X" query_logs failures by brand -> upserts catalog_coverage_gap insights (Telegram on P1), respecting founder triage. Add NO_CATALOG error bucket. Validated: both apps typecheck clean; tagger smoke 21/21; aggregation over 90d real data yields 13 brand gaps (Renault/Fiat/Honda P1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
29
apps/web/src/app/api/internal/catalog-gap-check/route.ts
Normal file
29
apps/web/src/app/api/internal/catalog-gap-check/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { detectCatalogCoverageGaps } from "@/lib/sase/catalog-coverage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const INTERNAL_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
|
||||
|
||||
function validToken(req: Request): boolean {
|
||||
if (!INTERNAL_TOKEN) return false;
|
||||
const provided = req.headers.get("x-internal-worker-token") ?? "";
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(INTERNAL_TOKEN);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
if (!validToken(req)) {
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const gaps = await detectCatalogCoverageGaps({ windowDays: 7, minFailures: 3 });
|
||||
return NextResponse.json({ ok: true, gaps });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
77
apps/web/src/lib/sase/catalog-coverage.ts
Normal file
77
apps/web/src/lib/sase/catalog-coverage.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
|
||||
// ─── Catalog coverage gaps ────────────────────────────────────────────────
|
||||
// The single biggest VIN-decode failure mode is "No catalog — identified as X":
|
||||
// the decoder *recognises* the vehicle (brand/year) but Sase has no parts
|
||||
// catalog mapped for it, so the user sees their car but no parts. These rows
|
||||
// land in query_logs with success=false and error_message starting
|
||||
// "No catalog — identified as <Brand> [<Year>]". We aggregate them by brand so
|
||||
// the worker can raise ONE insight per brand-gap instead of per failed query.
|
||||
export type CatalogGap = {
|
||||
brand: string;
|
||||
failures: number;
|
||||
uniqueUsers: number;
|
||||
labels: string[]; // distinct "<Brand> <Year>" identities seen, e.g. ["Renault 2004","Renault 2006"]
|
||||
years: number[]; // parsed model years (sorted)
|
||||
firstSeen: string; // ISO
|
||||
lastSeen: string; // ISO
|
||||
windowDays: number;
|
||||
};
|
||||
|
||||
export async function detectCatalogCoverageGaps(
|
||||
opts: { windowDays?: number; minFailures?: number } = {},
|
||||
): Promise<CatalogGap[]> {
|
||||
const windowDays = opts.windowDays ?? 7;
|
||||
const minFailures = opts.minFailures ?? 3;
|
||||
|
||||
// Brand is the first token of the identified label; initcap() folds HONDA/Honda
|
||||
// into one group. array_agg collects the distinct "<Brand> <Year>" identities so
|
||||
// the worker can show the affected model years.
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
brand: string;
|
||||
failures: number;
|
||||
unique_users: number;
|
||||
first_seen: Date;
|
||||
last_seen: Date;
|
||||
labels: string[];
|
||||
}>
|
||||
>`
|
||||
WITH nc AS (
|
||||
SELECT user_id, created_at,
|
||||
trim(substring(error_message FROM 'identified as (.*)$')) AS label
|
||||
FROM query_logs
|
||||
WHERE success = false
|
||||
AND created_at > now() - make_interval(days => ${windowDays})
|
||||
AND error_message LIKE 'No catalog%'
|
||||
)
|
||||
SELECT
|
||||
initcap(split_part(label, ' ', 1)) AS brand,
|
||||
count(*)::int AS failures,
|
||||
count(distinct user_id)::int AS unique_users,
|
||||
min(created_at) AS first_seen,
|
||||
max(created_at) AS last_seen,
|
||||
array_agg(DISTINCT label ORDER BY label) AS labels
|
||||
FROM nc
|
||||
WHERE label IS NOT NULL AND label <> ''
|
||||
GROUP BY 1
|
||||
HAVING count(*) >= ${minFailures}
|
||||
ORDER BY failures DESC
|
||||
`;
|
||||
|
||||
return rows.map((r) => {
|
||||
const years = Array.from(
|
||||
new Set(r.labels.flatMap((l) => (l.match(/\b(?:19|20)\d{2}\b/g) ?? []).map(Number))),
|
||||
).sort((a, b) => a - b);
|
||||
return {
|
||||
brand: r.brand,
|
||||
failures: r.failures,
|
||||
uniqueUsers: r.unique_users,
|
||||
labels: r.labels,
|
||||
years,
|
||||
firstSeen: r.first_seen.toISOString(),
|
||||
lastSeen: r.last_seen.toISOString(),
|
||||
windowDays,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -260,6 +260,7 @@ export type ErrorBucketRow = {
|
||||
|
||||
export const ERROR_BUCKET_KEYS = [
|
||||
"BUDGET_EXCEEDED",
|
||||
"NO_CATALOG",
|
||||
"UNKNOWN_VIN",
|
||||
"TIMEOUT",
|
||||
"INVALID_VIN",
|
||||
@@ -273,6 +274,9 @@ export type ErrorBucketKey = (typeof ERROR_BUCKET_KEYS)[number];
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [
|
||||
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
|
||||
// Vehicle identified but no parts catalog mapped — the #1 "OTHER" failure and
|
||||
// the server-side twin of the catalog_empty_result insight signal.
|
||||
{ key: "NO_CATALOG", matchers: [/no catalog/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] },
|
||||
|
||||
Reference in New Issue
Block a user