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:
@@ -3,7 +3,8 @@ import { getSnapshotSources, getSnapshotBlob, isConfigured } from "../lib/postho
|
||||
import { compressSnapshots, parseSnapshotBlob } from "../lib/compress";
|
||||
import { putText } from "../lib/minio";
|
||||
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";
|
||||
|
||||
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));
|
||||
}
|
||||
const { customEvents } = await loadEnrichment(s.id);
|
||||
const group = s.groupKey
|
||||
? await getCachedGroup("company", s.groupKey).catch(() => null)
|
||||
: null;
|
||||
let companyContext: string | null = null;
|
||||
if (s.groupKey) {
|
||||
const group = await getCachedGroup("company", s.groupKey).catch(() => null);
|
||||
if (group) {
|
||||
const p = group.properties;
|
||||
const parts: string[] = [];
|
||||
if (p.tier) parts.push(`tier=${p.tier}`);
|
||||
if (typeof p.mrr_usd === "number") parts.push(`mrr=$${p.mrr_usd}`);
|
||||
if (typeof p.seat_count === "number") parts.push(`seats=${p.seats_used ?? "?"}/${p.seat_count}`);
|
||||
if (p.industry) parts.push(`industry=${p.industry}`);
|
||||
companyContext = parts.join(", ") || null;
|
||||
}
|
||||
if (group) {
|
||||
const p = group.properties;
|
||||
const parts: string[] = [];
|
||||
if (p.tier) parts.push(`tier=${p.tier}`);
|
||||
if (typeof p.mrr_usd === "number") parts.push(`mrr=$${p.mrr_usd}`);
|
||||
if (typeof p.seat_count === "number") parts.push(`seats=${p.seats_used ?? "?"}/${p.seat_count}`);
|
||||
if (p.industry) parts.push(`industry=${p.industry}`);
|
||||
companyContext = parts.join(", ") || null;
|
||||
}
|
||||
|
||||
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({
|
||||
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) {
|
||||
|
||||
@@ -12,6 +12,11 @@ export type CompressedOutput = {
|
||||
sanitization: SanitizationReport;
|
||||
keyEvents: 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 = {
|
||||
@@ -77,6 +82,65 @@ export function compressSnapshots(
|
||||
const errors: string[] = [];
|
||||
const network5xx: 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;
|
||||
|
||||
const formatCustom = (ev: CanonicalEvent): string => {
|
||||
@@ -139,6 +203,11 @@ export function compressSnapshots(
|
||||
const tRel = Math.max(0, Math.round((ev.timestamp - t0) / 1000));
|
||||
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) {
|
||||
// Meta — URL changes
|
||||
const href = ev.data?.href;
|
||||
@@ -164,6 +233,8 @@ export function compressSnapshots(
|
||||
if (status >= 500) {
|
||||
network5xx.push(`${method} ${rurl}`);
|
||||
failedEndpoints.push(rurl);
|
||||
} else if (status >= 400) {
|
||||
network4xxCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +243,42 @@ export function compressSnapshots(
|
||||
if (ev.type === 3) {
|
||||
const d = ev.data ?? {};
|
||||
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) {
|
||||
const target = describeTarget(d);
|
||||
const now = ev.timestamp;
|
||||
@@ -181,6 +287,7 @@ export function compressSnapshots(
|
||||
if (consecutiveClicks >= 3 && !rageEmitted) {
|
||||
lines.push(`${ts} → 😡 rage_click ${target} (${consecutiveClicks} in <1.5s)`);
|
||||
rageEmitted = true;
|
||||
rageClickCount++;
|
||||
}
|
||||
} else {
|
||||
if (consecutiveClicks > 0 && !rageEmitted) {
|
||||
@@ -348,6 +455,9 @@ export function compressSnapshots(
|
||||
sanitization: report,
|
||||
keyEvents,
|
||||
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 {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
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 ───
|
||||
const vinFails = events.filter((e) => e.name === "vin_decode_failed");
|
||||
if (vinFails.length >= 2) {
|
||||
@@ -176,12 +184,15 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
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 (
|
||||
!tags.length &&
|
||||
s.isAuthenticated &&
|
||||
s.clickCount >= 20 &&
|
||||
s.errorCount === 0
|
||||
s.errorCount === 0 &&
|
||||
s.rageClickCount === 0
|
||||
) {
|
||||
tags.push("power_user_path");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user