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

__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:
2026-06-03 10:54:08 +03:00
parent 2a26890d33
commit fa6937bb5f
2 changed files with 36 additions and 7 deletions

View File

@@ -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";

View File

@@ -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).