From 897ae29113dc8f8cf9fc2ce63b930521aa3ebfa9 Mon Sep 17 00:00:00 2001 From: Felipe Salinas Rangel Date: Sun, 21 Jun 2026 18:59:10 -0500 Subject: [PATCH 1/3] 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 From b6508d33a861728097f00acd748b98161fd0215e Mon Sep 17 00:00:00 2001 From: cravenceiling <53354136+cravenceiling@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:55:05 -0500 Subject: [PATCH 2/3] 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) --- knowledge-base/data-rituals.md | 8 ++++---- knowledge-base/production-infra.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/knowledge-base/data-rituals.md b/knowledge-base/data-rituals.md index 0cb50e386..164c5bfe9 100644 --- a/knowledge-base/data-rituals.md +++ b/knowledge-base/data-rituals.md @@ -69,7 +69,7 @@ First 10 results = the day's queue. Open PostHog → **Growth** dashboard. Three numbers: - Yesterday's installs (acquisition tile) - Yesterday's DAU (engagement tile) -- Yesterday's activation rate (activation tile — % of new installs who hit `chat_message_received` within 24h) +- Yesterday's activation rate (activation tile — % of new installs who hit `chat_message_sent` within 24h) Compare each to the trailing 7-day average. If any is ≥ 20% off, dig. @@ -82,7 +82,7 @@ Open PostHog → **Acquisition Sources** tile. Look at `$initial_utm_campaign` b Open PostHog dashboards. For each metric below, note the value and the week-over-week change: - New installs -- Activated users (`chat_message_received` first-fire count) +- Activated users (`chat_message_sent` first-fire count) - D7 retention (% of last-week-Monday installs who came back) - Errors per user (`app_error_shown` count / DAU) - Top feature events (which `skill_used` / `tab_opened` values are up?) @@ -157,7 +157,7 @@ Open with: **"Where in the funnel do we lose people?"** Tiles: - Full funnel: `install_created` → `workspace_created` → `provider_configured` → `agent_created` → `chat_message_sent` → `chat_message_received` - Drop-off heatmap (which step bleeds the most users?) -- Time-to-activation distribution (median minutes from install to first reply) +- Time-to-activation distribution (median minutes from install to first message sent) - Activation rate, cohorted by signup week (is it getting better or worse over time?) Red flag: drop-off > 50% at any single step that wasn't there last week. @@ -221,7 +221,7 @@ Red flag: a heavily-active domain (> 5 users) suddenly drops to 1-2 users — li Defined once in PostHog → reuse in every insight. From `knowledge-base/production-infra.md`: -- **Activated users** — fired `chat_message_received` (the activation milestone) +- **Activated users** — fired `chat_message_sent` (the activation milestone) - **Stale-version users** — `app_version != latest`, for marketing-update emails to push people to update (improves Sentry symbolication coverage AND reduces bugs they hit) - **B2B users** — `email_domain in []` - **Power users** — top 10% by `total_messages_sent` (or `is_activated=true` if you haven't wired the counter yet) diff --git a/knowledge-base/production-infra.md b/knowledge-base/production-infra.md index 39a165fa5..52276bf17 100644 --- a/knowledge-base/production-infra.md +++ b/knowledge-base/production-infra.md @@ -20,8 +20,8 @@ Four prod systems. All **dormant by default** — activate only when env vars se - **Pure JS:** runs in webview, no Rust plugin. Avoids Tokio runtime conflicts. Works in future Capacitor mobile too. - **Init:** `app/src/lib/analytics.ts` — reads `POSTHOG_KEY` + `POSTHOG_HOST` via Vite `define` (baked at build time). Empty key → silent no-op. PostHog `init()` runs at module load for JS exception capture; product events fire after `analytics.init()` identifies the persistent install_id. - **PostHog config:** autocapture, pageview/pageleave, session replay, heatmaps, dead clicks, rage clicks, and feature-flag `/flags` calls are disabled in code. Enable any of these only with a specific question. -- **Install identity:** `app/src/lib/install-id.ts` — mints a UUID on first launch, persists via `tauriPreferences` (`install_id` key). Used as anonymous PostHog `distinct_id` until sign-in, then `analytics.alias/identify` merges history to the Supabase user. -- **User identity:** `distinct_id` is the stable Supabase user id. `email` and `email_domain` are PostHog person properties only, used for lookup, company-domain filtering, and B2B usage checks. +- **Install identity:** `app/src/lib/install-id.ts` — mints a UUID on first launch, persists via `tauriPreferences` (`install_id` key). Used as the PostHog `distinct_id` for the whole app lifetime — it STAYS the `distinct_id` after sign-in (the `/welcome` UTM bridge and the sequential onboarding funnel depend on it); sign-in attaches the Supabase identity as person properties instead of re-pointing it. +- **User identity:** on sign-in `analytics.identifyUser` stamps `supabase_user_id` (the Supabase `auth.users.id`) as a PERSON PROPERTY — that, not `distinct_id`, is the queryable join key to Supabase. **Join user-level metrics on `supabase_user_id`**, so one human on two devices (two `install_id`s, one `supabase_user_id`) dedupes. `email` and `signup_date` (set-once, from `auth.users.created_at`) are person properties too, used for lookup and company-domain filtering. `distinct_id` stays the device `install_id`. - **Debug/Release:** `import.meta.env.DEV` → `is_debug` super property. Filter it out in dashboards to exclude dev activity. - **Super properties:** `app_version`, `app_os` (normalized: `macos` / `windows` / `linux` / `unknown`), `os` (raw legacy `navigator.platform`), `install_id`, `is_debug`. - **Privacy:** no workspace names, agent names, raw prompts, raw message text, file paths, session keys, or raw error text in PostHog event props. Email is allowed only as a person property after auth, never as an event property. @@ -33,7 +33,7 @@ Four prod systems. All **dormant by default** — activate only when env vars se - **Engagement:** `mission_created` - **Reliability:** `session_failed`, `app_error_shown`, PostHog `$exception` from JS global handlers + React error boundary -**Activation milestone:** `chat_message_received` — user sent a message and got a reply. Configure as the activation event in PostHog; all retention/funnel insights key off it. +**Activation milestone:** `chat_message_sent` — the user sends their first message (activation = the user acts, not the agent's reply). The app flips the `is_activated` person property on this event; configure `chat_message_sent` as the activation event in PostHog so the server-side insights match the person property, and key all retention/funnel insights off it. **Changed from `chat_message_received` in PR #562** — `is_activated` values set before that ship date reflect the old reply-based definition, so treat the cutover as a discontinuity in any longitudinal activation comparison. ### Web ↔ app journey (one PostHog project) The marketing site (`website/`, Eleventy) shares the **same** `POSTHOG_KEY`, so the whole acquisition→activation journey is one project. @@ -105,7 +105,7 @@ PostHog → BigQuery plugin → target GCP project (burns credits). SQL-queryabl - **Session storage:** CI releases use macOS Keychain / Windows Credential Manager via the `keyring` crate (`app/src-tauri/src/auth.rs`). Local builds use browser storage scoped per worktree to avoid macOS Keychain prompts from changing local signatures. Override with `HOUSTON_AUTH_STORAGE=keychain` or `HOUSTON_AUTH_STORAGE=browser`. - **Flow:** One-click Google sign-in → system browser → OAuth redirect to `houston://auth-callback` → `tauri-plugin-deep-link` forwards to frontend → Supabase PKCE exchange → session persisted in configured auth storage. Full diagram + code pointers: `knowledge-base/auth.md`. - **Gating:** `isAuthConfigured()` checks whether `SUPABASE_URL` + `SUPABASE_ANON_KEY` are baked in. Unconfigured builds skip the sign-in screen entirely. -- **PostHog merge:** On sign-in, `analytics.alias(userId, { email })` merges anonymous install_id history to the identified user and sets `email` / `email_domain` person properties; on sign-out, `analytics.reset()` returns to anonymous. +- **PostHog identity:** On sign-in, `analytics.identifyUser(userId, { email, signupDate })` keeps `install_id` as the `distinct_id` and stamps `supabase_user_id` + `email` (`$set`) and `signup_date` (`$set_once`) as person properties, then flips the `auth_status` super property to `authenticated`; on sign-out, `analytics.reset()` returns to anonymous. Join on `supabase_user_id`, not `distinct_id`. ## Crash reporting (`sentry` + `tauri-plugin-sentry`) From 937d304ffcf7aa9a1b99d3271c8be4110029ae7c Mon Sep 17 00:00:00 2001 From: cravenceiling <53354136+cravenceiling@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:16:09 -0500 Subject: [PATCH 3/3] 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) --- app/src/App.tsx | 10 ++++++---- app/src/lib/analytics.ts | 30 ++++++++++++++++-------------- knowledge-base/production-infra.md | 6 +++--- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index f40743b2c..968669dbc 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -76,10 +76,12 @@ export default function App() { // 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. + // funnel depend on it); `identifyUser` aliases the Supabase id onto that person + // (merging the same human across devices/reinstalls) AND attaches + // supabase_user_id / email / signup date as person properties, so every + // authenticated person is both one PostHog person and joinable 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; diff --git a/app/src/lib/analytics.ts b/app/src/lib/analytics.ts index 78005a30c..1189d8188 100644 --- a/app/src/lib/analytics.ts +++ b/app/src/lib/analytics.ts @@ -311,27 +311,29 @@ export const analytics = { }, /** - * Stamp the signed-in user's Supabase identity onto the current person. - * Call on sign-in. + * Tie the signed-in user's Supabase identity to their PostHog person. + * Call on sign-in. Does two complementary things: * - * 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. + * 1. `alias(userId)` — adds the Supabase user id as an alias of the current + * install_id person. The distinct_id STAYS install_id (so the website + * `/welcome` UTM bridge and the sequential onboarding funnel are untouched), + * but because every device/reinstall aliases the SAME supabase id, PostHog + * stitches a human's separate per-device persons into ONE. alias is the call + * that merges; a second `identify()` with a new distinct_id is ignored once + * a person is identified, so identify is NOT a substitute here. + * 2. `setPersonProperties` — also stamps `supabase_user_id` (plus email `$set`, + * signup_date `$set_once`) so the id is a queryable join key to Supabase, + * not only an internal alias. Email is a person property for + * lookup/filtering, never an event prop. * - * 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. + * Finally flips the `auth_status` super property so every event going forward + * is tagged authenticated. */ identifyUser: (userId: string, identity?: UserIdentity) => { if (!KEY) return; try { const email = cleanEmail(identity?.email); + posthog.alias(userId); posthog.setPersonProperties( { supabase_user_id: userId, diff --git a/knowledge-base/production-infra.md b/knowledge-base/production-infra.md index 52276bf17..1eeca0bb5 100644 --- a/knowledge-base/production-infra.md +++ b/knowledge-base/production-infra.md @@ -20,8 +20,8 @@ Four prod systems. All **dormant by default** — activate only when env vars se - **Pure JS:** runs in webview, no Rust plugin. Avoids Tokio runtime conflicts. Works in future Capacitor mobile too. - **Init:** `app/src/lib/analytics.ts` — reads `POSTHOG_KEY` + `POSTHOG_HOST` via Vite `define` (baked at build time). Empty key → silent no-op. PostHog `init()` runs at module load for JS exception capture; product events fire after `analytics.init()` identifies the persistent install_id. - **PostHog config:** autocapture, pageview/pageleave, session replay, heatmaps, dead clicks, rage clicks, and feature-flag `/flags` calls are disabled in code. Enable any of these only with a specific question. -- **Install identity:** `app/src/lib/install-id.ts` — mints a UUID on first launch, persists via `tauriPreferences` (`install_id` key). Used as the PostHog `distinct_id` for the whole app lifetime — it STAYS the `distinct_id` after sign-in (the `/welcome` UTM bridge and the sequential onboarding funnel depend on it); sign-in attaches the Supabase identity as person properties instead of re-pointing it. -- **User identity:** on sign-in `analytics.identifyUser` stamps `supabase_user_id` (the Supabase `auth.users.id`) as a PERSON PROPERTY — that, not `distinct_id`, is the queryable join key to Supabase. **Join user-level metrics on `supabase_user_id`**, so one human on two devices (two `install_id`s, one `supabase_user_id`) dedupes. `email` and `signup_date` (set-once, from `auth.users.created_at`) are person properties too, used for lookup and company-domain filtering. `distinct_id` stays the device `install_id`. +- **Install identity:** `app/src/lib/install-id.ts` — mints a UUID on first launch, persists via `tauriPreferences` (`install_id` key). Used as the PostHog `distinct_id` for the whole app lifetime — it STAYS the `distinct_id` after sign-in (the `/welcome` UTM bridge and the sequential onboarding funnel depend on it); sign-in aliases the Supabase id onto it (merging the same human across devices) and attaches the identity as person properties, without re-pointing the distinct_id. +- **User identity:** on sign-in `analytics.identifyUser` does two things: (1) `alias(supabase_user_id)` stitches a human's per-device / per-reinstall persons into ONE PostHog person (each keeps its own `install_id` distinct_id; the shared alias merges them), so retention/WAU dedupe natively; (2) stamps `supabase_user_id` (the Supabase `auth.users.id`) as a PERSON PROPERTY — the queryable join key to Supabase. `email` and `signup_date` (set-once, from `auth.users.created_at`) are person properties too, used for lookup and company-domain filtering. `distinct_id` stays the device `install_id`. - **Debug/Release:** `import.meta.env.DEV` → `is_debug` super property. Filter it out in dashboards to exclude dev activity. - **Super properties:** `app_version`, `app_os` (normalized: `macos` / `windows` / `linux` / `unknown`), `os` (raw legacy `navigator.platform`), `install_id`, `is_debug`. - **Privacy:** no workspace names, agent names, raw prompts, raw message text, file paths, session keys, or raw error text in PostHog event props. Email is allowed only as a person property after auth, never as an event property. @@ -105,7 +105,7 @@ PostHog → BigQuery plugin → target GCP project (burns credits). SQL-queryabl - **Session storage:** CI releases use macOS Keychain / Windows Credential Manager via the `keyring` crate (`app/src-tauri/src/auth.rs`). Local builds use browser storage scoped per worktree to avoid macOS Keychain prompts from changing local signatures. Override with `HOUSTON_AUTH_STORAGE=keychain` or `HOUSTON_AUTH_STORAGE=browser`. - **Flow:** One-click Google sign-in → system browser → OAuth redirect to `houston://auth-callback` → `tauri-plugin-deep-link` forwards to frontend → Supabase PKCE exchange → session persisted in configured auth storage. Full diagram + code pointers: `knowledge-base/auth.md`. - **Gating:** `isAuthConfigured()` checks whether `SUPABASE_URL` + `SUPABASE_ANON_KEY` are baked in. Unconfigured builds skip the sign-in screen entirely. -- **PostHog identity:** On sign-in, `analytics.identifyUser(userId, { email, signupDate })` keeps `install_id` as the `distinct_id` and stamps `supabase_user_id` + `email` (`$set`) and `signup_date` (`$set_once`) as person properties, then flips the `auth_status` super property to `authenticated`; on sign-out, `analytics.reset()` returns to anonymous. Join on `supabase_user_id`, not `distinct_id`. +- **PostHog identity:** On sign-in, `analytics.identifyUser(userId, { email, signupDate })` keeps `install_id` as the `distinct_id`, `alias()`es the Supabase id onto the person (merging the human across devices/reinstalls), stamps `supabase_user_id` + `email` (`$set`) and `signup_date` (`$set_once`) as person properties, then flips the `auth_status` super property to `authenticated`; on sign-out, `analytics.reset()` returns to anonymous (a fresh distinct_id, which also prevents a shared device from merging two people). ## Crash reporting (`sentry` + `tauri-plugin-sentry`)