fix(insights): propagate rrweb counters & resolve click targets

Three coupled fixes that together stop frustrated sessions from being
mislabelled as `power_user_path` and make click timelines actually
diagnosable.

1) Counter propagation (compress.ts, compress-sessions.ts):
   compress now returns rageClickCount / network5xxCount / network4xxCount
   and writes them back to SessionMeta. Previously these stayed at 0
   forever because PostHog recording metadata doesn't expose them and
   nothing updated the row after compress ran.

2) Tag rule hardening (tagger.ts, compress-sessions.ts):
   - power_user_path v1 fallback now requires rageClickCount===0; without
     this guard any auth user with 20+ clicks (rage clusters included)
     was labelled a power user.
   - New frustrated_session tag (rageClickCount>=3, P2) for sustained
     friction beyond a single cluster.
   - compress-sessions re-runs tagSession+scoreSession after writing the
     fresh counters, so the corrected tags land on the row.

3) Click target enrichment (compress.ts):
   describeTarget used to read tagName/attributes off MouseInteraction
   events — fields that don't exist on rrweb type=3 source=2 — so every
   click rendered as `[el]`. Now compress builds a node map from
   FullSnapshot (type=2) and keeps it in sync with mutations
   (type=3 source=0), then resolves clicks via `d.id`. Clicks now show
   real tag + visible text / aria-label / name / data-testid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-22 21:49:06 +03:00
parent 94f279665c
commit 565ad5af59
3 changed files with 171 additions and 23 deletions

View File

@@ -3,7 +3,8 @@ import { getSnapshotSources, getSnapshotBlob, isConfigured } from "../lib/postho
import { compressSnapshots, parseSnapshotBlob } from "../lib/compress"; import { compressSnapshots, parseSnapshotBlob } from "../lib/compress";
import { putText } from "../lib/minio"; import { putText } from "../lib/minio";
import { loadEnrichment } from "../lib/enrich"; import { loadEnrichment } from "../lib/enrich";
import { getCachedGroup } from "../lib/posthog-cache"; import { getCachedGroup, getCachedPerson } from "../lib/posthog-cache";
import { tagSession, scoreSession } from "../lib/tagger";
import { alertSanitizationAnomaly } from "../lib/telegram"; import { alertSanitizationAnomaly } from "../lib/telegram";
const PANEL_URL = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai"; const PANEL_URL = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
@@ -41,18 +42,18 @@ export async function runCompressSessions(): Promise<{ compressed: number; faile
events.push(...parseSnapshotBlob(blob)); events.push(...parseSnapshotBlob(blob));
} }
const { customEvents } = await loadEnrichment(s.id); const { customEvents } = await loadEnrichment(s.id);
const group = s.groupKey
? await getCachedGroup("company", s.groupKey).catch(() => null)
: null;
let companyContext: string | null = null; let companyContext: string | null = null;
if (s.groupKey) { if (group) {
const group = await getCachedGroup("company", s.groupKey).catch(() => null); const p = group.properties;
if (group) { const parts: string[] = [];
const p = group.properties; if (p.tier) parts.push(`tier=${p.tier}`);
const parts: string[] = []; if (typeof p.mrr_usd === "number") parts.push(`mrr=$${p.mrr_usd}`);
if (p.tier) parts.push(`tier=${p.tier}`); if (typeof p.seat_count === "number") parts.push(`seats=${p.seats_used ?? "?"}/${p.seat_count}`);
if (typeof p.mrr_usd === "number") parts.push(`mrr=$${p.mrr_usd}`); if (p.industry) parts.push(`industry=${p.industry}`);
if (typeof p.seat_count === "number") parts.push(`seats=${p.seats_used ?? "?"}/${p.seat_count}`); companyContext = parts.join(", ") || null;
if (p.industry) parts.push(`industry=${p.industry}`);
companyContext = parts.join(", ") || null;
}
} }
const out = compressSnapshots( const out = compressSnapshots(
@@ -113,9 +114,42 @@ export async function runCompressSessions(): Promise<{ compressed: number; faile
}, },
}); });
// Re-tag with the freshly-derived rrweb counters. The tag step ran
// before compress and saw rageClickCount=0 etc., so rules like
// ux_friction/frustrated_session and the rage-guarded power_user_path
// fallback need a second pass once the real counts are known.
const updatedMeta = {
...s,
rageClickCount: out.rageClickCount,
network5xxCount: out.network5xxCount,
network4xxCount: out.network4xxCount,
};
const personCache = s.posthogDistinctId
? await getCachedPerson(s.posthogDistinctId).catch(() => null)
: null;
const retagCtx = {
customEvents,
userProperties: personCache?.properties ?? {},
groupProperties: group?.properties ?? null,
};
const retag = tagSession(updatedMeta, retagCtx);
const rescore = scoreSession(updatedMeta, retag.severity, 0.5, 0.2, retagCtx.groupProperties);
await prisma.sessionMeta.update({ await prisma.sessionMeta.update({
where: { id: s.id }, where: { id: s.id },
data: { status: "compressed", fingerprint: out.fingerprint, processedAt: new Date() }, data: {
status: "compressed",
fingerprint: out.fingerprint,
processedAt: new Date(),
// Backfill rrweb-derived counters that ingest can't see (PostHog
// recording metadata doesn't expose rage / 4xx / 5xx counts).
rageClickCount: out.rageClickCount,
network5xxCount: out.network5xxCount,
network4xxCount: out.network4xxCount,
tags: retag.tags,
severity: retag.severity,
score: rescore,
},
}); });
if (anomaly) { if (anomaly) {

View File

@@ -12,6 +12,11 @@ export type CompressedOutput = {
sanitization: SanitizationReport; sanitization: SanitizationReport;
keyEvents: string[]; keyEvents: string[];
hypotheses: string[]; hypotheses: string[];
// Rrweb-derived counters propagated back to SessionMeta so the tagger can
// see signals (e.g. rage clusters) that aren't available at ingest time.
rageClickCount: number;
network5xxCount: number;
network4xxCount: number;
}; };
type RREvent = { type RREvent = {
@@ -77,6 +82,65 @@ export function compressSnapshots(
const errors: string[] = []; const errors: string[] = [];
const network5xx: string[] = []; const network5xx: string[] = [];
const failedEndpoints: string[] = []; const failedEndpoints: string[] = [];
let rageClickCount = 0;
let network4xxCount = 0;
// rrweb node id → element info, populated from FullSnapshot (type=2) and kept
// in sync with mutations (type=3 source=0). MouseInteraction/Input events
// (type=3 source=2|5) only carry the rrweb `id` — without this map every
// click resolves to bare "[el]" and all UI-friction analysis loses signal.
type NodeInfo = {
nodeType?: number;
tagName?: string;
attrs?: Record<string, string>;
textContent?: string;
childIds: number[];
};
const nodeMap = new Map<number, NodeInfo>();
const indexNode = (n: any): void => {
if (!n || typeof n.id !== "number") return;
const info: NodeInfo = { nodeType: n.type, childIds: [] };
if (n.type === 2) {
// ELEMENT
info.tagName = String(n.tagName ?? "").toLowerCase();
if (n.attributes && typeof n.attributes === "object") info.attrs = n.attributes;
} else if (n.type === 3) {
// TEXT
info.textContent = typeof n.textContent === "string" ? n.textContent : "";
}
nodeMap.set(n.id, info);
if (Array.isArray(n.childNodes)) {
for (const c of n.childNodes) {
indexNode(c);
if (c && typeof c.id === "number") info.childIds.push(c.id);
}
}
};
const resolveText = (id: number, depth = 0): string => {
if (depth > 3) return "";
const node = nodeMap.get(id);
if (!node) return "";
if (node.nodeType === 3) return node.textContent ?? "";
let out = "";
for (const c of node.childIds) {
out += resolveText(c, depth + 1);
if (out.length > 60) break;
}
return out;
};
const describeTarget = (d: any): string => {
const id = typeof d?.id === "number" ? d.id : null;
if (id === null) return "[el]";
const node = nodeMap.get(id);
if (!node || !node.tagName) return "[el]";
const attrs = node.attrs ?? {};
const label = (resolveText(id) || attrs["aria-label"] || attrs["name"] || attrs["data-testid"] || "").trim();
const text = truncate(label, 20);
return `[${node.tagName}${text ? `:"${text}"` : ""}]`;
};
let url: string | null = header.startUrl; let url: string | null = header.startUrl;
const formatCustom = (ev: CanonicalEvent): string => { const formatCustom = (ev: CanonicalEvent): string => {
@@ -139,6 +203,11 @@ export function compressSnapshots(
const tRel = Math.max(0, Math.round((ev.timestamp - t0) / 1000)); const tRel = Math.max(0, Math.round((ev.timestamp - t0) / 1000));
const ts = formatTs(tRel); const ts = formatTs(tRel);
if (ev.type === 2) {
// FullSnapshot — seed the node map with the current DOM tree.
indexNode(ev.data?.node);
continue;
}
if (ev.type === 4) { if (ev.type === 4) {
// Meta — URL changes // Meta — URL changes
const href = ev.data?.href; const href = ev.data?.href;
@@ -164,6 +233,8 @@ export function compressSnapshots(
if (status >= 500) { if (status >= 500) {
network5xx.push(`${method} ${rurl}`); network5xx.push(`${method} ${rurl}`);
failedEndpoints.push(rurl); failedEndpoints.push(rurl);
} else if (status >= 400) {
network4xxCount++;
} }
} }
} }
@@ -172,7 +243,42 @@ export function compressSnapshots(
if (ev.type === 3) { if (ev.type === 3) {
const d = ev.data ?? {}; const d = ev.data ?? {};
const source = d.source; const source = d.source;
// 2 = MouseInteraction, 5 = Input, 6 = Scroll, 9 = ViewportResize // 0 = Mutation, 2 = MouseInteraction, 5 = Input, 6 = Scroll, 9 = ViewportResize
if (source === 0) {
// Keep node map in sync. We don't process removes (a stale entry only
// matters if a later click targets it, which can't happen for removed
// nodes); same for moves (parent re-link doesn't change label).
if (Array.isArray(d.adds)) {
for (const a of d.adds) {
indexNode(a?.node);
const parent = typeof a?.parentId === "number" ? nodeMap.get(a.parentId) : null;
if (parent && a?.node && typeof a.node.id === "number") {
parent.childIds.push(a.node.id);
}
}
}
if (Array.isArray(d.attributes)) {
for (const a of d.attributes) {
if (typeof a?.id !== "number") continue;
const node = nodeMap.get(a.id);
if (!node) continue;
node.attrs = node.attrs ?? {};
for (const [k, v] of Object.entries(a.attributes ?? {})) {
if (v === null) delete node.attrs[k];
else node.attrs[k] = String(v);
}
}
}
if (Array.isArray(d.texts)) {
for (const t of d.texts) {
if (typeof t?.id !== "number") continue;
const node = nodeMap.get(t.id);
if (!node) continue;
node.textContent = typeof t.value === "string" ? t.value : "";
}
}
continue;
}
if (source === 2) { if (source === 2) {
const target = describeTarget(d); const target = describeTarget(d);
const now = ev.timestamp; const now = ev.timestamp;
@@ -181,6 +287,7 @@ export function compressSnapshots(
if (consecutiveClicks >= 3 && !rageEmitted) { if (consecutiveClicks >= 3 && !rageEmitted) {
lines.push(`${ts} → 😡 rage_click ${target} (${consecutiveClicks} in <1.5s)`); lines.push(`${ts} → 😡 rage_click ${target} (${consecutiveClicks} in <1.5s)`);
rageEmitted = true; rageEmitted = true;
rageClickCount++;
} }
} else { } else {
if (consecutiveClicks > 0 && !rageEmitted) { if (consecutiveClicks > 0 && !rageEmitted) {
@@ -348,6 +455,9 @@ export function compressSnapshots(
sanitization: report, sanitization: report,
keyEvents, keyEvents,
hypotheses, hypotheses,
rageClickCount,
network5xxCount: network5xx.length,
network4xxCount,
}; };
} }
@@ -367,13 +477,6 @@ function stripQuery(u: string): string {
} }
} }
function describeTarget(d: any): string {
const tag = d?.tagName?.toLowerCase?.() ?? "el";
const attrs = d?.attributes ?? {};
const text = truncate(String(d?.text ?? attrs?.["aria-label"] ?? attrs?.name ?? ""), 20);
return `[${tag}${text ? `:"${text}"` : ""}]`;
}
function truncate(s: string, n: number): string { function truncate(s: string, n: number): string {
return s.length > n ? `${s.slice(0, n)}` : s; return s.length > n ? `${s.slice(0, n)}` : s;
} }

View File

@@ -62,6 +62,14 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
severity = bump(severity, "P2"); severity = bump(severity, "P2");
} }
// ─── 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) {
tags.push("frustrated_session");
severity = bump(severity, "P2");
}
// ─── VIN decode failure pattern ─── // ─── VIN decode failure pattern ───
const vinFails = events.filter((e) => e.name === "vin_decode_failed"); const vinFails = events.filter((e) => e.name === "vin_decode_failed");
if (vinFails.length >= 2) { if (vinFails.length >= 2) {
@@ -176,12 +184,15 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
tags.push("power_user_path"); tags.push("power_user_path");
} }
} }
// v1 fallback (rrweb-only): authenticated + many clicks + no errors and no other tag yet // v1 fallback (rrweb-only): authenticated + many clicks + no errors/rage and no other tag yet.
// Without the rage-click guard this fired on frustrated users (20+ clicks
// including rage clusters → mislabelled as power users).
if ( if (
!tags.length && !tags.length &&
s.isAuthenticated && s.isAuthenticated &&
s.clickCount >= 20 && s.clickCount >= 20 &&
s.errorCount === 0 s.errorCount === 0 &&
s.rageClickCount === 0
) { ) {
tags.push("power_user_path"); tags.push("power_user_path");
} }