fix(analytics): stop orphaning anonymous person on load, breaking signup attribution
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
__root identify effect called posthog.reset() whenever `user` was falsy — which includes the brief window while the session resolves on every page load. After the signup hard-redirect this rotated the anonymous distinct_id, orphaning the pre-signup anonymous person that carries `user_signed_up` and first-touch `$initial_utm_*`. The later identify() then merged a fresh, empty anon id, so signups never linked to trial/payment (only ~16% stitched) and channel attribution read "(none)" for 100% of signups. - __root.tsx: gate the effect on isLoading and only reset() on a real identified -> anonymous transition (logout), tracked via a ref. Logout still resets via dashboard handleSignOut. - register.tsx: identify() within the still-active anonymous session before firing user_signed_up, so the anon->identified merge carries $initial_utm_* and attributes the signup (email flow; Google is handled on OAuth return). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import { Button } from "@sase/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Link, Outlet, createRootRouteWithContext, useLocation } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Home, Search } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
@@ -93,7 +93,7 @@ function applyTheme(theme: "light" | "dark" | "system") {
|
||||
|
||||
function RootComponent() {
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { user, isLoading } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Pageview tracking
|
||||
@@ -102,9 +102,22 @@ function RootComponent() {
|
||||
trackMetaPageView();
|
||||
}, [location.pathname]);
|
||||
|
||||
// User identification
|
||||
// User identification.
|
||||
//
|
||||
// CRITICAL: never call posthog.reset() while the session is still resolving,
|
||||
// nor for first-time anonymous visitors. reset() rotates the anonymous
|
||||
// distinct_id; firing it on every load (user is briefly null while the
|
||||
// session loads) orphaned the pre-signup anonymous person — the one that
|
||||
// carries `user_signed_up` and the first-touch `$initial_utm_*`. The later
|
||||
// identify() then merged a fresh, empty anon id instead, so signups never
|
||||
// linked to trial/payment and channel attribution read "(none)" for everyone.
|
||||
// Only reset on a genuine identified -> anonymous transition (logout);
|
||||
// explicit logout also resets via dashboard.tsx's handleSignOut.
|
||||
const wasIdentifiedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isLoading) return; // session still resolving — leave identity untouched
|
||||
if (user) {
|
||||
wasIdentifiedRef.current = true;
|
||||
identifyUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -117,11 +130,12 @@ function RootComponent() {
|
||||
.get<{ identifier: string; identifierHash: string }>("/chatwoot/identity")
|
||||
.then((res) => setChatwootUser(user, res.identifierHash))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
} else if (wasIdentifiedRef.current) {
|
||||
wasIdentifiedRef.current = false;
|
||||
resetUser();
|
||||
resetChatwootUser();
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from "@/lib/api-client";
|
||||
import { signIn, signUp } from "@/lib/auth-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { track as trackMeta } from "@/lib/meta-pixel";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { capture, identifyUser } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cleanModelName } from "@/lib/vehicle";
|
||||
import { Button } from "@sase/ui";
|
||||
@@ -96,7 +96,7 @@ function RegisterPage() {
|
||||
// better-auth returns { error } rather than throwing (see login.tsx).
|
||||
// The old code awaited without checking error, so failed signups still
|
||||
// reported success, fired user_signed_up, and redirected to the dashboard.
|
||||
const { error } = await signUp.email({
|
||||
const { data, error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
@@ -129,6 +129,21 @@ function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Identify within the still-active anonymous session, BEFORE the signup
|
||||
// event and the hard redirect below. This merges the anonymous person
|
||||
// (which holds first-touch `$initial_utm_*`) into the user and attributes
|
||||
// `user_signed_up` to them — so channel → signup → trial/paid is linkable.
|
||||
// (Google OAuth redirects away here, so it's identified on return by
|
||||
// __root's effect instead.)
|
||||
if (data?.user) {
|
||||
identifyUser({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
role: (data.user as { role?: string }).role ?? "user",
|
||||
});
|
||||
}
|
||||
|
||||
// The referral code travels via `?ref=` in redirectUrl; the welcome
|
||||
// onboarding modal on the search page is the single place that applies it
|
||||
// (covers both email and Google OAuth signups).
|
||||
|
||||
Reference in New Issue
Block a user