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 { 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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user