#!/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);