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 = [
|
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] },
|
||||||
|
|||||||
196
apps/worker/src/jobs/catalog-gap-detect.ts
Normal file
196
apps/worker/src/jobs/catalog-gap-detect.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user