Skip to content

fix(analytics): identify every user in PostHog by Supabase user id - #562

Draft
felipesalinasr wants to merge 1 commit into
mainfrom
fix/posthog-supabase-user-identity
Draft

fix(analytics): identify every user in PostHog by Supabase user id#562
felipesalinasr wants to merge 1 commit into
mainfrom
fix/posthog-supabase-user-identity

Conversation

@felipesalinasr

Copy link
Copy Markdown
Collaborator

Problem

PostHog identifies the device (install_id), not the user, so events can't be joined to Supabase and one human across devices/reinstalls counts as multiple people. Verified against the live DB: signed-in users' PostHog distinct_ids do not match their auth.users.id (e.g. juanframartzz@gmail.com → PostHog 13aa0e33… vs Supabase 73c26eb8…).

Root cause

  1. analytics.init() calls posthog.identify(install_id) on every launch → locks distinct_id to the device.
  2. The sign-in path called analytics.alias(userId)posthog.identify(userId), but PostHog silently ignores re-identifying an already-identified person, so the Supabase id never landed anywhere queryable. Only email got $set, which masked the gap.

Fix

Keep install_id as the distinct_id — the website /welcome UTM bridge and the sequential onboarding funnel both depend on it — and attach the Supabase identity as person properties at sign-in. Every authenticated person now carries a reliable, queryable join key to Supabase, with pre-login attribution untouched.

  • analytics.ts: replace the misleading alias() with identifyUser(), which sets supabase_user_id + email ($set) and signup_date ($set_once) via posthog.setPersonProperties, and flips auth_status to authenticated.
  • App.tsx: call analytics.identifyUser(userId, { email, signupDate }) on sign-in (signup date from session.user.created_at).
  • Removed the now-unused alias(), personProps, and related types.

Also: activation definition

is_activated now flips on the user's first chat_message_sent (agreed definition: activation = the user sends a message) instead of chat_message_received (agent reply).

Why person property, not re-identify

Re-pointing the distinct_id to the user id would require dropping the install_id identify in init(), which would strand the website's UTM attribution (the /welcome bridge merges the anonymous website person into install_id). The person-property approach achieves a reliable Supabase join without that risk.

Analysis note: 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 correctly.

Scope / follow-ups (not in this PR)

  • Enrich the person with signup_country (from the geo-ip work) and profile_complete — needs the profiles row at sign-in.
  • Reliable internal/test-user exclusion (is_internal) — needs the team email list.
  • Optional: native cross-device person merge (would require a coordinated website + identify change).
  • Event dedup/definitions (app_error_shown vs error_shown, session_*, app_active/app_launched/user_returned).

Test plan

  • pnpm/bun typecheck + lint pass.
  • Local with empty POSTHOG_KEY → no-ops, no throws.
  • Staging: fresh install → onboarding → sign in → PostHog person shows supabase_user_id = the Supabase auth.users.id, email, signup_date, auth_status = authenticated; pre-login funnel still connects.
  • Sign out → reset() to anonymous.
  • Spot-check 10 recent signed-in emails: PostHog supabase_user_id matches Supabase id.

Full context: posthog-identity-unification.md / product-metrics-definitions.md (product specs).

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@felipesalinasr felipesalinasr self-assigned this Jun 22, 2026
cravenceiling added a commit that referenced this pull request Jun 25, 2026
…id identity (#570)

* fix(analytics): attach Supabase user id to PostHog person on sign-in

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 <noreply@anthropic.com>

* docs(analytics): align KB with Supabase-id identity + chat_message_sent activation

PR #562 changed the PostHog identity model and the activation milestone
but left the analytics KBs describing the old model. Bring them in sync.

production-infra.md:
- Install identity now STAYS install_id as distinct_id after sign-in
  (the /welcome UTM bridge + onboarding funnel depend on it); was
  documented as "alias/identify merges history to the Supabase user".
- User identity: supabase_user_id is the queryable join key (person
  property), not distinct_id. Join user-level metrics on supabase_user_id
  so one human on two devices dedupes. Drop the email_domain claim (never
  set in code).
- Activation milestone chat_message_received -> chat_message_sent, with a
  note to migrate the PostHog-side activation event so server insights
  match the is_activated person property, and that the cutover is a
  discontinuity in longitudinal activation comparisons.
- PostHog merge bullet rewritten to identifyUser(userId,{email,signupDate})
  + setPersonProperties (supabase_user_id/email $set, signup_date $set_once).

data-rituals.md:
- Activation tile, weekly activated-users count, time-to-activation, and
  the Activated-users cohort all keyed on chat_message_sent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(analytics): alias() so each user is one PostHog person, not one-per-device

PR #562 removed posthog.alias() and attached the Supabase id only as a
person property. Verified against the live project (396231): that leaves a
human fragmented into one PostHog person per install_id (21 persons for a
single test email), and production today still relies on the alias merge
($create_alias events). Shipping the removal would fragment every prod
user and force supabase_user_id dedupe in every insight.

The PR's premise that alias is "a no-op" conflated alias with identify:
identify(userId) is ignored once a person is identified, but alias(userId)
DOES merge -- each device/reinstall aliases the same Supabase id, so
PostHog stitches them into one person.

Fix: do both. alias(userId) merges the human across devices/reinstalls
(distinct_id stays install_id, so the /welcome UTM bridge + onboarding
funnel are untouched) AND setPersonProperties keeps supabase_user_id as
the queryable Supabase join key. reset() on sign-out still hands out a
fresh distinct_id, so a shared device can't merge two people.

Docs (production-infra.md) updated to the alias-merge + property model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Felipe Salinas Rangel <felipe.salinasr@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant