From 897ae29113dc8f8cf9fc2ce63b930521aa3ebfa9 Mon Sep 17 00:00:00 2001 From: Felipe Salinas Rangel Date: Sun, 21 Jun 2026 18:59:10 -0500 Subject: [PATCH] fix(analytics): attach Supabase user id to PostHog person on sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog identifies the device (install_id), not the user, so events could not be joined to Supabase and a user across devices/reinstalls counted as multiple people. Verified against the live DB: signed-in users' PostHog distinct_ids did not match their auth.users.id. Root cause: init() calls posthog.identify(install_id) at startup, which locks the distinct_id to the device. The sign-in path then called posthog.identify(userId), which PostHog silently ignores once a person is already identified — so the Supabase id never landed anywhere queryable (only email was $set, which masked the gap). Fix: keep install_id as the distinct_id (the website /welcome UTM bridge and the sequential onboarding funnel depend on it) and attach supabase_user_id + email + signup_date as PERSON PROPERTIES at sign-in. Every authenticated person now carries a reliable join key to Supabase with pre-login attribution untouched. Replaces the misleading alias() with identifyUser(). Also aligns activation with the agreed definition: is_activated now flips on the user's first chat_message_sent (user sends a message) instead of chat_message_received (agent reply). Join user-level metrics on supabase_user_id (not distinct_id) so a user on two devices (two install_ids, one supabase_user_id) dedupes. Co-Authored-By: Claude Opus 4.8 --- app/src/App.tsx | 12 +++++---- app/src/lib/analytics.ts | 53 +++++++++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 061d3b2be..f40743b2c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -74,17 +74,19 @@ export default function App() { const { data: session, isLoading: sessionLoading } = useSession(); - // Identify / alias the user in PostHog AND Sentry on sign-in; reset on - // sign-out. Runs AFTER analytics.init() has claimed the install_id as - // distinct_id, so `alias(userId, profile)` correctly merges prior - // anonymous history. Sentry gets the same identity so crashes are + // Tag the user in PostHog AND Sentry on sign-in; reset on sign-out. The + // install_id stays PostHog's distinct_id (the website UTM bridge + onboarding + // funnel depend on it); `identifyUser` attaches supabase_user_id / email / + // signup date as person properties so every authenticated person joins back + // to a Supabase account. Sentry gets the same identity so crashes are // attributable to a user when triaging. const prevUserIdRef = useRef(null); useEffect(() => { const userId = session?.user?.id ?? null; const userEmail = session?.user?.email ?? null; + const signupDate = session?.user?.created_at?.slice(0, 10) ?? null; if (userId && userId !== prevUserIdRef.current) { - analytics.alias(userId, { email: userEmail }); + analytics.identifyUser(userId, { email: userEmail, signupDate }); setSentryUser({ id: userId, email: userEmail }); prevUserIdRef.current = userId; } else if (!userId && prevUserIdRef.current) { diff --git a/app/src/lib/analytics.ts b/app/src/lib/analytics.ts index ab2cf5d90..78005a30c 100644 --- a/app/src/lib/analytics.ts +++ b/app/src/lib/analytics.ts @@ -101,11 +101,10 @@ type AnalyticsProperty = | "locale"; type Props = Partial>; -type UserProfile = { +type UserIdentity = { email?: string | null; -}; -type PersonProps = { - email?: string; + /** ISO date (YYYY-MM-DD) from auth.users.created_at — acquisition cohort. */ + signupDate?: string | null; }; const ALLOWED_PROPS = new Set([ @@ -192,11 +191,6 @@ function cleanEmail(email?: string | null): string | undefined { return value && at > 0 && at < value.length - 1 ? value : undefined; } -function personProps(profile?: UserProfile): PersonProps | undefined { - const email = cleanEmail(profile?.email); - return email ? { email } : undefined; -} - function daysBetween(fromISO: string, toISO: string): number { const a = new Date(fromISO).getTime(); const b = new Date(toISO).getTime(); @@ -304,10 +298,11 @@ export const analytics = { if (!KEY) return; try { posthog.capture(event, cleanProps(props)); - // Maintain the `is_activated` person property — flips to true on - // first `chat_message_received` and stays true forever. Lets cohort - // filters say "activated users" without a complex insight. - if (event === "chat_message_received") { + // Maintain the `is_activated` person property — flips to true on the + // user's first `chat_message_sent` (activation = the user sends a + // message) and stays true forever. Lets cohort filters say "activated + // users" without a complex insight. + if (event === "chat_message_sent") { posthog.people.set({ is_activated: true }); } } catch { @@ -316,16 +311,34 @@ export const analytics = { }, /** - * Merge anonymous install_id history into an identified user. Call on sign-in. - * Email is a person property for lookup/filtering, never an event prop. - * Flips the auth_status super property so every event going forward is - * tagged as authenticated. + * Stamp the signed-in user's Supabase identity onto the current person. + * Call on sign-in. + * + * We deliberately KEEP the install_id as PostHog's distinct_id — it is the + * spine the website `/welcome` UTM bridge and the sequential onboarding + * funnel both depend on. (PostHog ignores a second `identify()` with a new + * distinct_id once a person is identified, so re-pointing it is a silent + * no-op anyway.) Instead we attach `supabase_user_id` — plus email and signup + * date — as PERSON PROPERTIES, so every authenticated person carries a + * reliable, queryable join key to Supabase with pre-login attribution + * untouched. Email is a person property for lookup/filtering, never an event + * prop. Flips `auth_status` so every event going forward is authenticated. + * + * NOTE: join user-level metrics on `supabase_user_id` (not distinct_id) so a + * user signing in on two devices — two install_ids, one supabase_user_id — + * dedupes correctly. */ - alias: (userId: string, profile?: UserProfile) => { + identifyUser: (userId: string, identity?: UserIdentity) => { if (!KEY) return; try { - posthog.alias(userId); - posthog.identify(userId, personProps(profile)); + const email = cleanEmail(identity?.email); + posthog.setPersonProperties( + { + supabase_user_id: userId, + ...(email ? { email } : {}), + }, + identity?.signupDate ? { signup_date: identity.signupDate } : undefined, + ); posthog.register({ ...baseSuperProps(), auth_status: "authenticated" }); } catch { // Analytics unavailable