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 = [ export const ERROR_BUCKET_KEYS = [
"BUDGET_EXCEEDED", "BUDGET_EXCEEDED",
"NO_CATALOG",
"UNKNOWN_VIN", "UNKNOWN_VIN",
"TIMEOUT", "TIMEOUT",
"INVALID_VIN", "INVALID_VIN",
@@ -273,6 +274,9 @@ export type ErrorBucketKey = (typeof ERROR_BUCKET_KEYS)[number];
const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [ const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] }, { 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: "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] },
{ key: "INVALID_VIN", matchers: [/geçersiz/i, /invalid vin/i] }, { key: "INVALID_VIN", matchers: [/geçersiz/i, /invalid vin/i] },

View File

@@ -0,0 +1,196 @@
import { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { alertCatalogGap, isTelegramConfigured } from "../lib/telegram";
// Server-side companion to the session-driven insight pipeline. The behavioural
// pipeline (PostHog → tagger → analyze) catches "user resolved a vehicle but saw
// no parts" from client events (catalog_empty_result / parts_render_blocked).
// This job catches the SAME failure from the server side — query_logs rows where
// decode succeeded at identifying the car but no parts catalog exists
// ("No catalog — identified as X") — and raises one Insight per brand-gap into
// the founder's existing inbox. Panel owns the Sase DB; we fetch the aggregate
// over the internal API (same trust model as vin-anomaly-detect) and write rows.
const PANEL_BASE =
process.env.PANEL_INTERNAL_URL ?? process.env.PANEL_PUBLIC_URL ?? "http://panel-web:3000";
const PANEL_PUBLIC = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
const WORKER_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
type CatalogGap = {
brand: string;
failures: number;
uniqueUsers: number;
labels: string[];
years: number[];
firstSeen: string;
lastSeen: string;
windowDays: number;
};
type CheckResponse = { ok: boolean; error?: string; gaps?: CatalogGap[] };
type Summary = {
ok: boolean;
gaps: number;
created: number;
updated: number;
alertsFired: number;
reason?: string;
};
function brandSlug(b: string): string {
return b.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
// P1 once a gap bites real breadth (≥4 distinct users or ≥15 failed lookups in
// the window) — a core-value failure for a whole brand; otherwise P2.
function severityFor(g: CatalogGap): "P1" | "P2" {
return g.uniqueUsers >= 4 || g.failures >= 15 ? "P1" : "P2";
}
function priorityFor(sev: string, users: number): number {
return (sev === "P1" ? 80 : 55) + Math.min(15, users);
}
// Shape the body around keys the insight detail page already renders
// (hypothesis / affected_route / user_impact_estimate / suggested_investigation /
// reproduce_steps); extra keys still show in the Raw JSON panel.
function buildBody(g: CatalogGap): Prisma.InputJsonValue {
const yrs = g.years.length ? ` (${g.years[0]}${g.years[g.years.length - 1]})` : "";
const labelList = g.labels.slice(0, 12).join(", ");
return {
summary: `${g.brand}${yrs}: araç decode'da tanınıyor ama parça kataloğu bulunamıyor ("No catalog"). Son ${g.windowDays} günde ${g.failures} başarısız sorgu / ${g.uniqueUsers} kullanıcı.`,
hypothesis: `Decode aracı doğru tanıyor ama "tanınan araç → parça kataloğu" eşlemesi ${g.brand} için boş dönüyor. Hatalar belirli model yıllarında kümeleniyor (${labelList}) → muhtemelen katalog MAPPING eksikliği (EMEX/PCAT kaynağında veri var ama brand/yıl eşleşmiyor) ya da o segment için kaynak verisi hiç yok.`,
affected_route: `/dashboard/catalog/${g.brand}, /dashboard/vehicles/:id (decode → "No catalog")`,
user_impact_estimate: `${g.uniqueUsers} kullanıcı son ${g.windowDays} günde ${g.brand} için parça göremedi → doğrudan churn sinyali. ${g.brand} TR pazarında yaygın bir marka.`,
suggested_investigation: [
`vehicles.service'teki katalog lookup zincirinde "No catalog" branch'ini incele (apps/api .../vehicles/vehicles.service.ts)`,
`sase-catalog-src-emex / sase-catalog-src-pcat DB'lerinde ${g.brand} (${labelList}) var mı — veri mi eksik, mapping mi bozuk?`,
`Tanınan brand/model_year → catalog brand/subcatalog eşlemesini kontrol et`,
],
reproduce_steps: [
`Katalogdan ${g.brand} seç (ya da bu markaya ait bir VIN decode et)`,
`Bir model/yıl seç (${g.labels[0] ?? g.brand})`,
`Parça/kategori yerine boş sonuç / "No catalog" gözlenir`,
],
suggested_fix_effort: "M",
affected_labels: g.labels,
total_failures: g.failures,
unique_users: g.uniqueUsers,
window_days: g.windowDays,
};
}
export async function runCatalogGapDetect(): Promise<Summary> {
if (!WORKER_TOKEN) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: "INTERNAL_WORKER_TOKEN not set" };
}
let res: Response;
try {
res = await fetch(`${PANEL_BASE}/api/internal/catalog-gap-check`, {
headers: { "x-internal-worker-token": WORKER_TOKEN, "cache-control": "no-store" },
});
} catch (e) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: `fetch failed: ${(e as Error).message}` };
}
if (!res.ok) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: `panel returned ${res.status}` };
}
const data = (await res.json()) as CheckResponse;
if (!data.ok) {
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: data.error };
}
const gaps = data.gaps ?? [];
const day = new Date().toISOString().slice(0, 10);
let created = 0;
let updated = 0;
let alertsFired = 0;
for (const g of gaps) {
const fingerprint = `catalog_gap:${brandSlug(g.brand)}`;
const sev = severityFor(g);
const body = buildBody(g);
const title = `Katalog yok: ${g.brand}${g.uniqueUsers} kullanıcı parça göremiyor (son ${g.windowDays}g)`;
const existing = await prisma.insight.findUnique({
where: { projectKey_fingerprint: { projectKey: PROJECT_KEY, fingerprint } },
});
if (existing) {
// Refresh counts/severity but RESPECT founder triage: a dismissed/duplicate
// gap stays dismissed (no resurrection). A gap the founder had already
// validated/shipped that is failing again flips to "regressed" + alerts.
const regressed = ["validated", "shipped"].includes(existing.status);
await prisma.insight.update({
where: { id: existing.id },
data: {
title,
body,
severity: sev,
occurrenceCount: g.failures,
uniqueUserCount: g.uniqueUsers,
lastSeenAt: new Date(g.lastSeen),
priorityScore: priorityFor(sev, g.uniqueUsers),
...(regressed ? { status: "regressed", regressionDetected: true } : {}),
},
});
updated++;
if (regressed && isTelegramConfigured()) {
const r = await alertCatalogGap({
brand: g.brand,
failures: g.failures,
uniqueUsers: g.uniqueUsers,
windowDays: g.windowDays,
severity: sev,
insightId: existing.id,
panelUrl: PANEL_PUBLIC,
day,
regressed: true,
});
if (r.ok && !r.deduped) alertsFired++;
}
continue;
}
const row = await prisma.insight.create({
data: {
projectKey: PROJECT_KEY,
type: "catalog_coverage_gap",
severity: sev,
status: "new",
fingerprint,
title,
body,
relatedSessionIds: [],
occurrenceCount: g.failures,
uniqueUserCount: g.uniqueUsers,
firstSeenAt: new Date(g.firstSeen),
lastSeenAt: new Date(g.lastSeen),
confidence: 1.0,
priorityScore: priorityFor(sev, g.uniqueUsers),
sourcePromptTag: "catalog_gap_detector",
sourcePromptVersion: 0,
sourceModel: "rule:catalog-gap",
sourceCostUsd: 0,
},
});
created++;
if (isTelegramConfigured()) {
const r = await alertCatalogGap({
brand: g.brand,
failures: g.failures,
uniqueUsers: g.uniqueUsers,
windowDays: g.windowDays,
severity: sev,
insightId: row.id,
panelUrl: PANEL_PUBLIC,
day,
regressed: false,
});
if (r.ok && !r.deduped) alertsFired++;
}
}
return { ok: true, gaps: gaps.length, created, updated, alertsFired };
}

View File

@@ -47,6 +47,23 @@ export const TRACKED_EVENTS: string[] = [
"parts_export_completed", "parts_export_completed",
"oem_code_copied", "oem_code_copied",
// Catalog / category browsing — Sase.tr's core value path (vehicle → categories
// → parts). These were previously NOT fetched, so a vehicle that resolved but
// rendered no parts/categories (the #1 churn complaint) was invisible to the
// tagger. `empty_catalog_cta_clicked` / `parts_panel_viewed.parts_count=0` are
// the concrete empty-state signals; the rest let us tell "drilled in but got
// nothing" apart from "decoded and bounced".
"catalog_search_opened",
"catalog_brands_viewed",
"catalog_brand_clicked",
"catalog_subcatalog_selected",
"catalog_models_viewed",
"catalog_model_clicked",
"catalog_locked_brand_upgrade_clicked",
"category_view_changed",
"empty_catalog_cta_clicked",
"part_reference_clicked",
// Payment (v1 + v2) // Payment (v1 + v2)
"payment_initiated", "payment_initiated",
"payment_success", "payment_success",

View File

@@ -98,6 +98,7 @@ export function customEventPromoteReasons(eventNames: string[]): string[] {
"subscription_cancelled", "subscription_cancelled",
"trial_urgency_banner_cta_clicked", "trial_urgency_banner_cta_clicked",
"downgrade_offer_shown", "downgrade_offer_shown",
"empty_catalog_cta_clicked", // resolved a vehicle but the catalog was empty
]; ];
for (const e of single) if (set.has(e)) reasons.push(`event:${e}`); for (const e of single) if (set.has(e)) reasons.push(`event:${e}`);

View File

@@ -322,8 +322,16 @@ export function pickPromptTag(tags: string[]): string {
) )
return "provider_quality"; return "provider_quality";
// Bugs // Bugs (incl. core-value parts/category render failures — bug_triage carries
if (set.has("bug_suspected") || set.has("server_error_impact")) return "bug_triage"; // is_likely_provider_issue / implicated_provider so the model can attribute an
// empty catalog to a provider data gap vs. a render/query defect).
if (
set.has("bug_suspected") ||
set.has("server_error_impact") ||
set.has("parts_render_blocked") ||
set.has("catalog_empty_result")
)
return "bug_triage";
// Onboarding // Onboarding
if (set.has("onboarding_stuck")) return "onboarding_stuck"; if (set.has("onboarding_stuck")) return "onboarding_stuck";

View File

@@ -121,5 +121,78 @@ const tags = (s: SessionMeta, events: CanonicalEvent[], up: Record<string, unkno
assert("validation-friction: frustrated_session NOT suppressed", t.includes("frustrated_session")); assert("validation-friction: frustrated_session NOT suppressed", t.includes("frustrated_session"));
} }
// 8) THE serkan case: signed-up trial user, VIN decodes (sees model), drills the
// category tree + clicks a catalog model, but parts NEVER render — zero rage,
// zero error, zero 5xx. A demo category that *does* return parts must not mask
// the real-page failure, and touching the search box must not silence it.
{
const t = tags(
mkSession({ rageClickCount: 0, errorCount: 0, network5xxCount: 0 }),
[
ev("search_input_focused"),
ev("vin_decode_succeeded"),
ev("category_view_changed"),
ev("category_view_changed"),
ev("category_view_changed"),
ev("catalog_model_clicked", { brand_name: "Ford" }),
// demo categories that DO return parts — must be excluded from the signal:
ev("parts_panel_viewed", { parts_count: 13, $current_url: "https://sase.tr/demo/categories/x" }),
],
);
assert("serkan: parts_render_blocked fired", t.includes("parts_render_blocked"));
assert("serkan: was caught (not tag-less / not search-misuse suppressed)", t.length > 0);
}
// 9) Parts panel renders EMPTY on a real dashboard page (parts_count=0, never non-empty).
{
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("parts_panel_viewed", {
parts_count: 0,
$current_url: "https://sase.tr/dashboard/vehicles/abc/categories/def",
}),
]);
assert("empty-panel: catalog_empty_result fired", t.includes("catalog_empty_result"));
assert("empty-panel: not falsely 'blocked' (panel did render)", !t.includes("parts_render_blocked"));
}
// 10) Explicit empty-state CTA → concrete empty result.
{
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("empty_catalog_cta_clicked", { vehicle_label: "Ford Focus", category_name: "mekanik" }),
]);
assert("empty-cta: catalog_empty_result fired", t.includes("catalog_empty_result"));
}
// 11) Happy path: parts actually render (count>0) → no parts-failure tags.
{
const url = "https://sase.tr/dashboard/vehicles/abc/categories/def";
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("catalog_model_clicked"),
ev("parts_panel_viewed", { parts_count: 20, $current_url: url }),
ev("oem_code_copied", { $current_url: url }),
]);
assert("happy: no parts_render_blocked", !t.includes("parts_render_blocked"));
assert("happy: no catalog_empty_result", !t.includes("catalog_empty_result"));
}
// 12) Decode-and-bounce (no drilling toward parts) → no false positive.
{
const t = tags(mkSession({}), [ev("vin_decode_succeeded")]);
assert("bounce: no parts_render_blocked", !t.includes("parts_render_blocked"));
}
// 13) Locked brand → upgrade prompt: paywall, not a defect.
{
const t = tags(mkSession({}), [
ev("vin_decode_succeeded"),
ev("catalog_model_clicked", { brand_name: "Mercedes" }),
ev("catalog_locked_brand_upgrade_clicked", { brand_name: "Mercedes" }),
]);
assert("locked: no parts_render_blocked (paywall, not bug)", !t.includes("parts_render_blocked"));
}
console.log(`\ntagger smoke: ${pass} passed, ${fail} failed`); console.log(`\ntagger smoke: ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1); if (fail > 0) process.exit(1);

View File

@@ -47,6 +47,58 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
const userProps = ctx?.userProperties ?? {}; const userProps = ctx?.userProperties ?? {};
const groupProps = ctx?.groupProperties ?? null; const groupProps = ctx?.groupProperties ?? null;
// ─── Parts / category render signals (Sase.tr core-value path) ───
// The product's whole job: resolve a vehicle (VIN or catalog) → list its
// categories + parts. When that final step yields *nothing* the product has
// failed for the user even though VIN decode "succeeded" and no JS error/5xx
// fired — the catalog returned an empty panel, 0 models, or the user hit the
// explicit empty-state CTA. This used to be invisible (the catalog_* family
// wasn't even fetched), so a real prospect could browse Ford/Opel, see no
// parts, and churn with no insight raised. Scoped to the real /dashboard
// product: /demo is a separate curated marketing surface whose (sometimes
// working) categories must NOT mask a genuine in-product failure.
const inDemo = (e: CanonicalEvent): boolean =>
/\/demo(\/|\?|$)/.test(
String(e.properties["$current_url"] ?? e.properties["$pathname"] ?? ""),
);
const realPartsViews = events.filter((e) => e.name === "parts_panel_viewed" && !inDemo(e));
const successfulPartsView = realPartsViews.some((e) => Number(e.properties.parts_count) > 0);
const emptyPartsView = realPartsViews.some((e) => Number(e.properties.parts_count) === 0);
const emptyModelsList = events.some(
(e) => e.name === "catalog_models_viewed" && !inDemo(e) && Number(e.properties.count) === 0,
);
const explicitEmptyCatalog = events.some(
(e) => e.name === "empty_catalog_cta_clicked" && !inDemo(e),
);
const realOemCopied = events.some((e) => e.name === "oem_code_copied" && !inDemo(e));
// Shape 1 — "the system said: nothing here": panel rendered empty (and never
// non-empty in this session), 0 models listed, or the empty-state CTA shown.
const catalogEmptyResult =
explicitEmptyCatalog || emptyModelsList || (emptyPartsView && !successfulPartsView);
// Shape 2 — "drilled in and got nothing at all": resolved a vehicle and
// actively browsed categories/models but never saw a single part. Gated on
// real engagement (not a decode-and-bounce) and excludes the paywall case
// (locked brand → upgrade prompt, which is a conversion signal, not a defect).
const resolvedVehicle =
has(events, "vin_decode_succeeded") ||
has(events, "vin_decode_candidate_selected") ||
has(events, "catalog_model_clicked");
const browsedForParts =
count(events, "category_view_changed") >= 2 ||
has(events, "catalog_model_clicked") ||
has(events, "catalog_subcatalog_selected");
const partsRenderBlocked =
resolvedVehicle &&
browsedForParts &&
!successfulPartsView &&
!realOemCopied &&
!has(events, "catalog_locked_brand_upgrade_clicked");
// Any genuine parts/category failure disqualifies the search-misuse guard
// below — such a session is a real product defect, never "fiddling with the
// search box, nothing broken".
const partsFailure = catalogEmptyResult || partsRenderBlocked;
// ─── Expected search-box misuse → not insight-worthy ─── // ─── Expected search-box misuse → not insight-worthy ───
// The /dashboard/search box is VIN-only, but users routinely use it to look // The /dashboard/search box is VIN-only, but users routinely use it to look
// for a part by *name* (e.g. "Cam düğme") or fiddle with search/history and // for a part by *name* (e.g. "Cam düğme") or fiddle with search/history and
@@ -61,6 +113,7 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
const searchAffordanceMisuse = const searchAffordanceMisuse =
s.errorCount === 0 && s.errorCount === 0 &&
s.network5xxCount === 0 && s.network5xxCount === 0 &&
!partsFailure &&
has(events, "search_input_focused") && has(events, "search_input_focused") &&
!has(events, "vin_decode_failed") && !has(events, "vin_decode_failed") &&
count(events, "search_input_validation_failed") < 3 && count(events, "search_input_validation_failed") < 3 &&
@@ -200,6 +253,19 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
severity = bump(severity, "P3"); severity = bump(severity, "P3");
} }
// ─── Parts / category render failure (signals computed at top) ───
// Core-value failure: the user got a vehicle but no parts/categories. This is
// the highest-intent churn signal Sase.tr has — surface it as a concrete bug,
// not generic friction.
if (catalogEmptyResult) {
tags.push("catalog_empty_result");
severity = bump(severity, "P2");
}
if (partsRenderBlocked) {
tags.push("parts_render_blocked");
severity = bump(severity, "P1");
}
// ─── Search friction ─── // ─── Search friction ───
if (count(events, "search_input_validation_failed") >= 3) { if (count(events, "search_input_validation_failed") >= 3) {
tags.push("search_validation_friction"); tags.push("search_validation_friction");

View File

@@ -72,6 +72,33 @@ export function alertP0Insight(opts: {
return sendTelegram({ text, dedupeKey: `p0:${opts.insightId}` }); return sendTelegram({ text, dedupeKey: `p0:${opts.insightId}` });
} }
export function alertCatalogGap(opts: {
brand: string;
failures: number;
uniqueUsers: number;
windowDays: number;
severity: string;
insightId: string;
panelUrl: string;
day: string;
regressed?: boolean;
}): Promise<TelegramSendResult> {
const head = opts.regressed
? `↩️ <b>Katalog açığı GERİ DÖNDÜ</b> [${opts.severity}]`
: `🗂️ <b>Katalog kapsama açığı</b> [${opts.severity}]`;
const text = [
head,
`<b>${escapeHtml(opts.brand)}</b>: ${opts.failures} başarısız sorgu / ${opts.uniqueUsers} kullanıcı (son ${opts.windowDays}g)`,
`Araç tanınıyor ama parça kataloğu yok → kullanıcı parça göremiyor.`,
``,
`<a href="${opts.panelUrl}/insights/i/${opts.insightId}">Insight</a> · <a href="${opts.panelUrl}/projects/sase/vin-decode">VIN dashboard</a>`,
].join("\n");
return sendTelegram({
text,
dedupeKey: `catalog_gap:${opts.regressed ? "regress" : "new"}:${opts.brand}:${opts.day}`,
});
}
export function alertRegression(opts: { export function alertRegression(opts: {
insightId: string; insightId: string;
title: string; title: string;

View File

@@ -10,6 +10,7 @@ import { runRetention } from "../jobs/retention";
import { runEvalSet } from "../jobs/eval-run"; import { runEvalSet } from "../jobs/eval-run";
import { runDailyBrief } from "../jobs/daily-brief"; import { runDailyBrief } from "../jobs/daily-brief";
import { runVinAnomalyDetect } from "../jobs/vin-anomaly"; import { runVinAnomalyDetect } from "../jobs/vin-anomaly";
import { runCatalogGapDetect } from "../jobs/catalog-gap-detect";
import { runArchivePosthogEvents } from "../jobs/posthog-event-archive"; import { runArchivePosthogEvents } from "../jobs/posthog-event-archive";
import { runArchiveRecordings } from "../jobs/archive-recordings"; import { runArchiveRecordings } from "../jobs/archive-recordings";
import { runArchiveIdentity } from "../jobs/posthog-identity-archive"; import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
@@ -91,6 +92,15 @@ async function runJob(job: Job) {
} }
return res; return res;
} }
case "catalog-gap-detect": {
const res = await runCatalogGapDetect();
if (res.created > 0 || res.updated > 0 || !res.ok) {
console.log(
`[pipeline] catalog-gap gaps=${res.gaps} created=${res.created} updated=${res.updated} alerts=${res.alertsFired}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
case "posthog-event-archive": { case "posthog-event-archive": {
const res = await runArchivePosthogEvents(); const res = await runArchivePosthogEvents();
if (res.fetched > 0 || res.dumped > 0 || res.error) { if (res.fetched > 0 || res.dumped > 0 || res.error) {
@@ -181,6 +191,11 @@ export async function startInsightPipeline() {
{ pattern: "*/5 * * * *" }, { pattern: "*/5 * * * *" },
{ name: "vin-anomaly-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } }, { name: "vin-anomaly-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
); );
await queue.upsertJobScheduler(
"catalog-gap-detect",
{ pattern: "30 */6 * * *" },
{ name: "catalog-gap-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler( await queue.upsertJobScheduler(
"posthog-event-archive", "posthog-event-archive",
{ pattern: "*/15 * * * *" }, { pattern: "*/15 * * * *" },
@@ -209,7 +224,7 @@ export async function startInsightPipeline() {
stalledInterval: 60_000, stalledInterval: 60_000,
}); });
console.log( console.log(
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly", "[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, catalog-gap-detect@*/6h, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly",
); );
return worker; return worker;
} }