fix(insights): suppress no-insight search-affordance misuse in tagger

Users routinely use the VIN-only /dashboard/search box 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. The generic
ux_friction / frustrated_session tags turned these pure-rage sessions into
insights (noise; ~40 of the first 67 open insights were exactly this, all
dismissed by the founder).

apps/worker/src/lib/tagger.ts: when a session used the search box
(search_input_focused) but carries NO concrete failure (errorCount=0,
network5xxCount=0, no vin_decode_failed, search_input_validation_failed<3, no
payment_initiated/failed, no checkout_started), do not emit the generic
ux_friction / frustrated_session tags. With no other actionable tag the session
becomes tag-less and tag-sessions discards it → no compress / analyze / insight.

Scoped narrowly to search-box sessions on purpose, to avoid hiding genuine
parts/category bugs. Every concrete signal stays actionable: JS errors
(bug_suspected), 5xx (server_error_impact), payment friction, VIN upstream
provider failures, and ≥3 client validation failures (search_validation_friction).

Smoke: apps/worker/src/lib/tagger.smoke.ts (12/12) covers the misuse case plus
six must-keep cases. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-06-03 21:35:58 +03:00
parent b61d955256
commit 556cfd6ba0
3 changed files with 149 additions and 3 deletions

View File

@@ -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:*",

View File

@@ -0,0 +1,125 @@
/**
* 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"));
}
console.log(`\ntagger smoke: ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);

View File

@@ -47,6 +47,27 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
const userProps = ctx?.userProperties ?? {};
const groupProps = ctx?.groupProperties ?? null;
// ─── 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 &&
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 +78,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 +86,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");
}