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:
Semih
2026-06-10 14:22:09 +03:00
parent 7a56a72037
commit 1d5daf2e5c
11 changed files with 516 additions and 3 deletions

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

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

View File

@@ -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] },