Files
sase.tr/scripts/check-posthog-identified-only.mjs
Fusion 584cda2b6c feat(FN-399): cross-session funnel attribution audit + regression check
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
2026-05-16 11:07:29 +03:00

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);