Commits merged: - feat(FN-399): cross-session funnel attribution audit + regression check Files changed: docs/product/funnel-audit-p0-cro-2026-05.md | 52 +++++++ scripts/check-posthog-identified-only.mjs | 220 ++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) Fusion-Task-Id: FN-399
221 lines
8.3 KiB
JavaScript
221 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// FN-399 — Cross-session funnel attribution regression check.
|
|
//
|
|
// Why this exists: the P0/P1 CRO funnel
|
|
// `vin_decode_success → checkout_started → payment_initiated → payment_success`
|
|
// spans multiple sessions per user (VIN decode often happens anonymously on the
|
|
// landing page; checkout and payment happen later, post-login). PostHog can
|
|
// only join those events into one funnel per user when:
|
|
//
|
|
// 1. `person_profiles: "identified_only"` is set on init, so anonymous
|
|
// pre-login events still emit but get reconciled to the user once
|
|
// `posthog.identify()` is called.
|
|
// 2. `identifyUser()` exists in `lib/posthog.ts` and calls
|
|
// `ph.identify(user.id, ...)`.
|
|
// 3. `resetUser()` exists and calls `ph.reset()` so logout doesn't leak the
|
|
// prior user's distinct_id onto the next visitor on the same browser.
|
|
// 4. The auth bootstrap (`routes/__root.tsx`) actually invokes both, so the
|
|
// config above isn't dead code.
|
|
// 5. All four cross-session funnel events still emit with their canonical
|
|
// `lowercase_snake` names — frontend (`apps/web/src`) via `capture("evt", …)`
|
|
// or backend (`apps/api/src`) via `captureForUser(userId, "evt", …)` /
|
|
// `capture("evt", …)`. Server-side `captureForUser` passes the user id
|
|
// as `distinctId`, so the event joins the same person profile that the
|
|
// frontend `identify()` created — the cross-session join still holds.
|
|
//
|
|
// If any of those drift, the funnel silently breaks: events still flow but
|
|
// PostHog can no longer stitch sessions to a user, and every CRO attribution
|
|
// number at the user level becomes unreliable. This script fails when that
|
|
// drift is introduced so the regression is caught at PR / CI time.
|
|
//
|
|
// Run: `node scripts/check-posthog-identified-only.mjs`
|
|
// Exit: 0 on PASS, 1 on FAIL.
|
|
|
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(__dirname, "..");
|
|
|
|
const POSTHOG_LIB = "apps/web/src/lib/posthog.ts";
|
|
const AUTH_BOOTSTRAP = "apps/web/src/routes/__root.tsx";
|
|
const EVENT_SOURCES = ["apps/web/src", "apps/api/src"];
|
|
|
|
const REQUIRED_EVENTS = [
|
|
"vin_decode_success",
|
|
"checkout_started",
|
|
"payment_initiated",
|
|
"payment_success",
|
|
];
|
|
|
|
const failures = [];
|
|
const notes = [];
|
|
|
|
function read(relPath) {
|
|
try {
|
|
return readFileSync(resolve(repoRoot, relPath), "utf8");
|
|
} catch (err) {
|
|
failures.push(`Cannot read ${relPath}: ${err.message}`);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// 1. person_profiles must be "identified_only" in the posthog-js init call.
|
|
const lib = read(POSTHOG_LIB);
|
|
if (lib) {
|
|
const personProfilesMatch = lib.match(/person_profiles\s*:\s*["']([^"']+)["']/);
|
|
if (!personProfilesMatch) {
|
|
failures.push(
|
|
`${POSTHOG_LIB}: \`person_profiles\` option is missing from the posthog-js init. ` +
|
|
"Without this, PostHog defaults to creating a person profile for every anonymous " +
|
|
"visitor, which breaks user-level cross-session funnel joins.",
|
|
);
|
|
} else if (personProfilesMatch[1] !== "identified_only") {
|
|
failures.push(
|
|
`${POSTHOG_LIB}: \`person_profiles\` is "${personProfilesMatch[1]}", expected ` +
|
|
'"identified_only". Drift here means anonymous pre-login VIN decode events ' +
|
|
"won't reconcile to the user that later signs in and checks out.",
|
|
);
|
|
} else {
|
|
notes.push(`${POSTHOG_LIB}: person_profiles = "identified_only" ok`);
|
|
}
|
|
|
|
// 2. identifyUser must exist and call ph.identify(user.id, ...).
|
|
if (!/export function identifyUser\b/.test(lib)) {
|
|
failures.push(`${POSTHOG_LIB}: \`identifyUser\` export is missing.`);
|
|
} else if (!/ph\.identify\(\s*user\.id/.test(lib)) {
|
|
failures.push(
|
|
`${POSTHOG_LIB}: \`identifyUser\` no longer calls \`ph.identify(user.id, ...)\`. ` +
|
|
"Without identify() the anonymous events never stitch to a user profile.",
|
|
);
|
|
} else {
|
|
notes.push(`${POSTHOG_LIB}: identifyUser -> ph.identify(user.id, …) ok`);
|
|
}
|
|
|
|
// 3. resetUser must exist and call ph.reset() — required on logout so the
|
|
// next anonymous visitor on the same browser doesn't inherit the prior
|
|
// user's distinct_id.
|
|
if (!/export function resetUser\b/.test(lib) || !/ph\.reset\(\)/.test(lib)) {
|
|
failures.push(
|
|
`${POSTHOG_LIB}: \`resetUser\` / \`ph.reset()\` is missing. Logout must reset ` +
|
|
"PostHog identity, otherwise the next anonymous visitor on the same browser " +
|
|
"inherits the prior user's distinct_id.",
|
|
);
|
|
} else {
|
|
notes.push(`${POSTHOG_LIB}: resetUser -> ph.reset() ok`);
|
|
}
|
|
}
|
|
|
|
// 4. The auth bootstrap must wire identifyUser to the authenticated user and
|
|
// resetUser to the signed-out branch.
|
|
const root = read(AUTH_BOOTSTRAP);
|
|
if (root) {
|
|
if (!/identifyUser\s*\(/.test(root)) {
|
|
failures.push(
|
|
`${AUTH_BOOTSTRAP}: \`identifyUser(...)\` is not invoked. Config is correct ` +
|
|
"but never fires, so PostHog stays anonymous for every user.",
|
|
);
|
|
} else {
|
|
notes.push(`${AUTH_BOOTSTRAP}: identifyUser(...) invoked ok`);
|
|
}
|
|
if (!/resetUser\s*\(/.test(root)) {
|
|
failures.push(
|
|
`${AUTH_BOOTSTRAP}: \`resetUser()\` is not invoked on sign-out. Logout will ` +
|
|
"leak the previous user's identity to the next visitor on the same browser.",
|
|
);
|
|
} else {
|
|
notes.push(`${AUTH_BOOTSTRAP}: resetUser() invoked ok`);
|
|
}
|
|
}
|
|
|
|
// 5. All four funnel events must still be captured — frontend
|
|
// (`apps/web/src`) via `capture("evt", …)` from `lib/posthog.ts`, or
|
|
// backend (`apps/api/src`) via `captureForUser(userId, "evt", …)` /
|
|
// `capture("evt", …)` from `posthog/posthog.service.ts`. We walk both
|
|
// trees in node (no shell-quoting issues) and only count matches in
|
|
// production .ts/.tsx — tests, node_modules, and build artifacts are
|
|
// excluded so we don't false-pass on a mock string.
|
|
function* walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const name of entries) {
|
|
if (name === "node_modules" || name === "__tests__" || name === "dist") continue;
|
|
const full = join(dir, name);
|
|
let st;
|
|
try {
|
|
st = statSync(full);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (st.isDirectory()) {
|
|
yield* walk(full);
|
|
} else if (
|
|
st.isFile() &&
|
|
/\.(ts|tsx)$/.test(name) &&
|
|
!/\.(test|spec)\.(ts|tsx)$/.test(name)
|
|
) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sourceFiles = [];
|
|
for (const root of EVENT_SOURCES) {
|
|
for (const f of walk(resolve(repoRoot, root))) sourceFiles.push(f);
|
|
}
|
|
|
|
function findEmit(eventName) {
|
|
// Frontend: `capture("evt", …)` from lib/posthog.ts.
|
|
// Backend: `captureForUser(userId, "evt", …)` or `capture("evt", …)` from
|
|
// PostHogService. `captureForUser` passes userId as distinctId, so the
|
|
// event joins the same person profile the frontend `identify()` created
|
|
// — the cross-session join still holds.
|
|
const fe = new RegExp(`\\bcapture\\(\\s*["']${eventName}["']`);
|
|
const beUser = new RegExp(`\\bcaptureForUser\\(\\s*[^,]+,\\s*["']${eventName}["']`);
|
|
for (const f of sourceFiles) {
|
|
let src;
|
|
try {
|
|
src = readFileSync(f, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const rel = f.slice(repoRoot.length + 1);
|
|
if (beUser.test(src)) return { kind: "captureForUser (server)", path: rel };
|
|
if (fe.test(src)) {
|
|
const kind = rel.startsWith("apps/api/") ? "capture (server)" : "capture (client)";
|
|
return { kind, path: rel };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
for (const event of REQUIRED_EVENTS) {
|
|
const hit = findEmit(event);
|
|
if (hit) {
|
|
notes.push(`event: ${event} → ${hit.kind} @ ${hit.path} ok`);
|
|
} else {
|
|
failures.push(
|
|
`Funnel event \`${event}\` is not captured anywhere under ${EVENT_SOURCES.join(" or ")}. ` +
|
|
"Cross-session funnel `vin_decode_success → checkout_started → payment_initiated " +
|
|
"→ payment_success` cannot be reconstructed without all four steps emitting.",
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Report ──
|
|
const headline =
|
|
failures.length === 0
|
|
? "PASS — PostHog identified_only config + cross-session funnel events intact."
|
|
: `FAIL — ${failures.length} issue(s) detected.`;
|
|
|
|
console.log(`[check-posthog-identified-only] ${headline}`);
|
|
for (const n of notes) console.log(` ok ${n}`);
|
|
for (const f of failures) console.log(` FAIL ${f}`);
|
|
|
|
process.exit(failures.length === 0 ? 0 : 1);
|