Compare commits
3 Commits
fix/insigh
...
feat/insig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5daf2e5c | ||
|
|
7a56a72037 | ||
|
|
556cfd6ba0 |
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] },
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx src/lib/dedup.smoke.ts"
|
||||
"test": "tsx src/lib/dedup.smoke.ts && tsx src/lib/tagger.smoke.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@panel/web": "workspace:*",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Worker } from "bullmq";
|
||||
import { startEventBus } from "./consumers/event-bus";
|
||||
import { startScheduledJobs } from "./schedulers/nightly";
|
||||
import { startInsightPipeline } from "./schedulers/pipeline";
|
||||
@@ -6,6 +7,8 @@ import { upsertSeedData } from "./lib/seed-runtime";
|
||||
import { redis } from "./redis";
|
||||
import { prisma } from "./db";
|
||||
|
||||
const workers: Worker[] = [];
|
||||
|
||||
async function main() {
|
||||
console.log("[worker] starting…");
|
||||
await redis.ping();
|
||||
@@ -15,16 +18,27 @@ async function main() {
|
||||
|
||||
await upsertSeedData().catch((e) => console.warn("[seed] failed:", e.message));
|
||||
|
||||
await startScheduledJobs();
|
||||
await startInsightPipeline();
|
||||
await startContentPipeline();
|
||||
workers.push(await startScheduledJobs());
|
||||
workers.push(await startInsightPipeline());
|
||||
workers.push(await startContentPipeline());
|
||||
await startEventBus();
|
||||
|
||||
console.log("[worker] up.");
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (sig: string) => {
|
||||
console.log(`[worker] ${sig} — shutting down`);
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`[worker] ${sig} — closing ${workers.length} workers gracefully`);
|
||||
// Close workers FIRST: this stops them taking new jobs and releases held job
|
||||
// locks, so the next container doesn't inherit half-finished jobs as "stalled"
|
||||
// and spam "Missing lock … moveToFinished". Bounded so an in-flight job can't
|
||||
// block the shutdown past the orchestrator's stop grace period.
|
||||
await Promise.race([
|
||||
Promise.allSettled(workers.map((w) => w.close())),
|
||||
new Promise((resolve) => setTimeout(resolve, 15_000)),
|
||||
]);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
await redis.quit().catch(() => {});
|
||||
process.exit(0);
|
||||
|
||||
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",
|
||||
"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_initiated",
|
||||
"payment_success",
|
||||
|
||||
@@ -98,6 +98,7 @@ export function customEventPromoteReasons(eventNames: string[]): string[] {
|
||||
"subscription_cancelled",
|
||||
"trial_urgency_banner_cta_clicked",
|
||||
"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}`);
|
||||
|
||||
|
||||
@@ -322,8 +322,16 @@ export function pickPromptTag(tags: string[]): string {
|
||||
)
|
||||
return "provider_quality";
|
||||
|
||||
// Bugs
|
||||
if (set.has("bug_suspected") || set.has("server_error_impact")) return "bug_triage";
|
||||
// Bugs (incl. core-value parts/category render failures — bug_triage carries
|
||||
// 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
|
||||
if (set.has("onboarding_stuck")) return "onboarding_stuck";
|
||||
|
||||
198
apps/worker/src/lib/tagger.smoke.ts
Normal file
198
apps/worker/src/lib/tagger.smoke.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Smoke test for the tagger search-affordance-misuse suppression.
|
||||
* Run: pnpm --filter worker exec tsx src/lib/tagger.smoke.ts
|
||||
*
|
||||
* Guarantees the new guard is SURGICAL: it silences generic ux_friction /
|
||||
* frustrated_session ONLY for "user fiddling with the VIN search box, nothing
|
||||
* broken" sessions, while every concrete failure stays actionable.
|
||||
*/
|
||||
import type { SessionMeta } from "@prisma/client";
|
||||
import type { CanonicalEvent } from "./event-taxonomy";
|
||||
import { tagSession } from "./tagger";
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function assert(label: string, cond: boolean): void {
|
||||
if (cond) pass++;
|
||||
else {
|
||||
fail++;
|
||||
console.error(`✗ ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ev = (name: string, properties: Record<string, unknown> = {}): CanonicalEvent =>
|
||||
({ name, properties, rawName: name } as unknown as CanonicalEvent);
|
||||
|
||||
function mkSession(over: Partial<SessionMeta>): SessionMeta {
|
||||
return {
|
||||
errorCount: 0,
|
||||
rageClickCount: 0,
|
||||
deadClickCount: 0,
|
||||
network5xxCount: 0,
|
||||
startUrl: "https://sase.tr/dashboard/search",
|
||||
durationMs: 200_000,
|
||||
isAuthenticated: true,
|
||||
clickCount: 5,
|
||||
subscriptionTier: null,
|
||||
startedAt: new Date(1_700_000_000_000),
|
||||
...over,
|
||||
} as unknown as SessionMeta;
|
||||
}
|
||||
const ctx = (events: CanonicalEvent[], userProperties: Record<string, unknown> = {}) =>
|
||||
({ customEvents: events, userProperties, groupProperties: null });
|
||||
|
||||
const tags = (s: SessionMeta, events: CanonicalEvent[], up: Record<string, unknown> = {}) =>
|
||||
tagSession(s, ctx(events, up)).tags;
|
||||
|
||||
// 1) The reported case: VIN search-box misuse, 17 rage clicks, NOTHING broken → suppressed.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 17, errorCount: 0, network5xxCount: 0 }),
|
||||
[ev("search_input_focused"), ev("search_history_item_selected"), ev("parts_panel_viewed")],
|
||||
);
|
||||
assert("misuse: no ux_friction", !t.includes("ux_friction"));
|
||||
assert("misuse: no frustrated_session", !t.includes("frustrated_session"));
|
||||
assert("misuse: tag-less → will be discarded", t.length === 0);
|
||||
}
|
||||
|
||||
// 2) Real JS errors + rage → still bug_suspected (misuse guard off when errorCount>0).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 5, errorCount: 2 }),
|
||||
[ev("search_input_focused")],
|
||||
);
|
||||
assert("js-error: bug_suspected kept", t.includes("bug_suspected"));
|
||||
}
|
||||
|
||||
// 3) Server 5xx storm → server_error_impact kept (misuse guard off when 5xx>0).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 4, network5xxCount: 3 }),
|
||||
[ev("search_input_focused")],
|
||||
);
|
||||
assert("5xx: server_error_impact kept", t.includes("server_error_impact"));
|
||||
}
|
||||
|
||||
// 4) Payment friction on a search session → conversion signal kept (misuse guard off).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 4 }),
|
||||
[ev("search_input_focused"), ev("payment_initiated")],
|
||||
);
|
||||
assert("payment: payment_friction kept", t.includes("payment_friction"));
|
||||
assert("payment: frustrated_session NOT suppressed", t.includes("frustrated_session"));
|
||||
}
|
||||
|
||||
// 5) Category-tree rage WITHOUT the search box → still surfaces (scope = search box only).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 10, startUrl: "https://sase.tr/dashboard/vehicles/x/categories/y" }),
|
||||
[ev("parts_panel_viewed")],
|
||||
);
|
||||
assert("category: ux_friction kept (no search_input_focused)", t.includes("ux_friction"));
|
||||
assert("category: frustrated_session kept", t.includes("frustrated_session"));
|
||||
}
|
||||
|
||||
// 6) Real upstream VIN decode failures on a search session → provider signal kept.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 3 }),
|
||||
[
|
||||
ev("search_input_focused"),
|
||||
ev("vin_decode_failed", { provider_attempted: "PL24" }),
|
||||
ev("vin_decode_failed", { provider_attempted: "PL24" }),
|
||||
],
|
||||
);
|
||||
assert("vin-fail: vin_decode_fail_pattern kept", t.includes("vin_decode_fail_pattern"));
|
||||
}
|
||||
|
||||
// 7) Search box used but ≥3 validation failures → that has its own P3 signal, not suppressed.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 6 }),
|
||||
[
|
||||
ev("search_input_focused"),
|
||||
ev("search_input_validation_failed"),
|
||||
ev("search_input_validation_failed"),
|
||||
ev("search_input_validation_failed"),
|
||||
],
|
||||
);
|
||||
assert("validation-friction: search_validation_friction kept", t.includes("search_validation_friction"));
|
||||
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`);
|
||||
if (fail > 0) process.exit(1);
|
||||
@@ -47,6 +47,80 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
const userProps = ctx?.userProperties ?? {};
|
||||
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 ───
|
||||
// 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
|
||||
// rage-click out of affordance confusion — while *nothing is actually broken*
|
||||
// (no JS errors, no 5xx, no decode/validation/provider failure, no payment).
|
||||
// These pure-affordance rage sessions are noise, not product defects, so we
|
||||
// do NOT let the generic ux_friction / frustrated_session tags fire for them;
|
||||
// with no other actionable tag the session ends up tag-less → discarded
|
||||
// (no compress / analyze / insight). Real failures still carry a concrete
|
||||
// event below and stay actionable. Scoped narrowly to search-box sessions on
|
||||
// purpose, to avoid hiding genuine parts/category bugs.
|
||||
const searchAffordanceMisuse =
|
||||
s.errorCount === 0 &&
|
||||
s.network5xxCount === 0 &&
|
||||
!partsFailure &&
|
||||
has(events, "search_input_focused") &&
|
||||
!has(events, "vin_decode_failed") &&
|
||||
count(events, "search_input_validation_failed") < 3 &&
|
||||
!has(events, "payment_initiated") &&
|
||||
!has(events, "payment_failed") &&
|
||||
!has(events, "checkout_started");
|
||||
|
||||
// ─── Bug detection (rrweb-based, generic fallback) ───
|
||||
if (s.errorCount > 0 && (s.rageClickCount > 0 || s.network5xxCount > 0)) {
|
||||
tags.push("bug_suspected");
|
||||
@@ -57,7 +131,7 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
severity = bump(severity, "P1");
|
||||
}
|
||||
// ─── UX friction ───
|
||||
if (s.errorCount === 0 && (s.rageClickCount > 0 || s.deadClickCount > 0)) {
|
||||
if (s.errorCount === 0 && (s.rageClickCount > 0 || s.deadClickCount > 0) && !searchAffordanceMisuse) {
|
||||
tags.push("ux_friction");
|
||||
severity = bump(severity, "P2");
|
||||
}
|
||||
@@ -65,7 +139,7 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
// ─── Frustrated session ───
|
||||
// Sustained rage clicking (3+ clusters) is a stronger signal than a single
|
||||
// cluster — promote it past ux_friction so it surfaces above generic noise.
|
||||
if (s.rageClickCount >= 3) {
|
||||
if (s.rageClickCount >= 3 && !searchAffordanceMisuse) {
|
||||
tags.push("frustrated_session");
|
||||
severity = bump(severity, "P2");
|
||||
}
|
||||
@@ -179,6 +253,19 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
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 ───
|
||||
if (count(events, "search_input_validation_failed") >= 3) {
|
||||
tags.push("search_validation_friction");
|
||||
|
||||
@@ -72,6 +72,33 @@ export function alertP0Insight(opts: {
|
||||
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: {
|
||||
insightId: string;
|
||||
title: string;
|
||||
|
||||
@@ -49,11 +49,12 @@ export async function startContentPipeline() {
|
||||
{ name: "content-generate", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
|
||||
);
|
||||
|
||||
new Worker(QUEUE, runJob, {
|
||||
const worker = new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
concurrency: 1,
|
||||
lockDuration: 5 * 60_000,
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
console.log("[content] armed: content-topics@*/8h, content-generate@*/10min");
|
||||
return worker;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,15 @@ export async function startScheduledJobs() {
|
||||
{ name: "panel-backup", data: {}, opts: { removeOnComplete: 30, removeOnFail: 30 } },
|
||||
);
|
||||
|
||||
new Worker(QUEUE, runJob, { connection: redis, concurrency: 1 });
|
||||
const worker = new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
concurrency: 1,
|
||||
// panel-backup (pg_dump) can run longer than the 30s default lock; a too-short
|
||||
// lock makes the job look "stalled", gets re-run, and the original then fails
|
||||
// its moveToFinished with "Missing lock". Match the other queues' 5min lock.
|
||||
lockDuration: 5 * 60_000,
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
console.log("[scheduler] armed: nightly-refresh@03:00, audit-archive@03:30, panel-backup@04:00");
|
||||
return worker;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { runRetention } from "../jobs/retention";
|
||||
import { runEvalSet } from "../jobs/eval-run";
|
||||
import { runDailyBrief } from "../jobs/daily-brief";
|
||||
import { runVinAnomalyDetect } from "../jobs/vin-anomaly";
|
||||
import { runCatalogGapDetect } from "../jobs/catalog-gap-detect";
|
||||
import { runArchivePosthogEvents } from "../jobs/posthog-event-archive";
|
||||
import { runArchiveRecordings } from "../jobs/archive-recordings";
|
||||
import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
|
||||
@@ -91,6 +92,15 @@ async function runJob(job: Job) {
|
||||
}
|
||||
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": {
|
||||
const res = await runArchivePosthogEvents();
|
||||
if (res.fetched > 0 || res.dumped > 0 || res.error) {
|
||||
@@ -181,6 +191,11 @@ export async function startInsightPipeline() {
|
||||
{ pattern: "*/5 * * * *" },
|
||||
{ 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(
|
||||
"posthog-event-archive",
|
||||
{ pattern: "*/15 * * * *" },
|
||||
@@ -202,13 +217,14 @@ export async function startInsightPipeline() {
|
||||
{ name: "sentry-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
|
||||
new Worker(QUEUE, runJob, {
|
||||
const worker = new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
concurrency: 2,
|
||||
lockDuration: 5 * 60_000,
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user