Description
Fixes duplicate PostHog persons for the same human when they sign up via more than one OAuth provider (Google + GitHub + magic link).
Root cause
The dashboard previously identified users to PostHog via the Better Auth Users.id UUID:
// src/clients/analytics/react.ts (before)
identify({
userId: user.id, // <- new Better Auth UUID per provider mismatch
propertiesToSet: { email, name, platform_user_id: user.id },
});
Apollo's Better Auth config (hermes/apps/apollo/src/lib/better_auth/index.ts:550-555) enables accountLinking but uses the defaults — links only when the second provider returns a lowercased-equal email. That doesn't hold in two common cases:
- GitHub email privacy — when "Keep my email addresses private" is on, GitHub returns
<id>+<user>@users.noreply.github.com, which never matches a real Gmail/work address.
- Different primary emails per provider — e.g., Google
alice@gmail.com, GitHub alice@workdomain.com.
Better Auth then falls through to createOAuthUser, mints a brand-new Users row with a new UUID, and the dashboard posthog.identify(user.id, …) produces a separate PostHog person for the same human. That matches the team's report that duplicates only happen when users try multiple providers — single-provider users never trip the bug.
Fix
- Switch the PostHog
distinct_id to lowercased email (a new getDistinctIdForEmail() helper in src/clients/analytics/distinct-id.ts). One human → one email → one PostHog person, even when Better Auth couldn't link the OAuth accounts on the backend.
- Keep
user.id as a platform_user_id person property so backend correlation (Apollo logs, ClickHouse) still works.
- On the first identify per user, also call
posthog.alias(user.id, emailDistinctId) so any legacy person profile keyed on user.id merges into the new email-keyed canonical profile. Gated by localStorage (posthog_user_id_aliased_<id>) so it only fires once.
- Apply the same change server-side in
getDistinctId(), aliasAnonymousCookie(), and identifyUser() so server page-view events stay aligned with the client identity (otherwise funnels break).
How did I test this PR
doppler run -- pnpm typecheck — passes.
doppler run -- pnpm lint — same baseline as main (102 warnings / 5 errors, all pre-existing in unrelated files; zero new findings in my touched files, verified via git stash + diff).
- Manually traced both call paths (
AnalyticsIdentifier mount on (private)/layout.tsx, and trackPageView / trackServerEvent server-side helpers) to confirm both client and server identify calls now derive distinct_id from user.email.toLowerCase().
- Confirmed with
grep that posthog.identify is now only called from the two analytics modules — no other call sites slipped through.
Notes / follow-ups
- This fix prevents future duplicates and merges existing duplicates on a user's next login (via the alias). Pre-existing duplicate persons from users who never log back in are unaffected; if needed they can be merged manually via the PostHog admin UI.
- A separate gap exists:
signOut() (src/clients/auth/index.ts:21-28) does not call posthog.reset(), so identity bleeds across users on shared browsers. Out of scope for this PR — happy to follow up if the team wants it bundled.
- Longer-term: tightening Apollo's
accountLinking config (e.g., prompting users with private GitHub emails to expose them, or surfacing manual "link this account" UI) would address the underlying root cause in Better Auth itself.