diff --git a/app/src/App.tsx b/app/src/App.tsx index 061d3b2be..968669dbc 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -74,17 +74,21 @@ 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 - // attributable to a user when triaging. + // 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` 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; 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..1189d8188 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,36 @@ 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. + * Tie the signed-in user's Supabase identity to their PostHog person. + * Call on sign-in. Does two complementary things: + * + * 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. + * + * Finally flips the `auth_status` super property so every event going forward + * is tagged authenticated. */ - alias: (userId: string, profile?: UserProfile) => { + identifyUser: (userId: string, identity?: UserIdentity) => { if (!KEY) return; try { + const email = cleanEmail(identity?.email); posthog.alias(userId); - posthog.identify(userId, personProps(profile)); + posthog.setPersonProperties( + { + supabase_user_id: userId, + ...(email ? { email } : {}), + }, + identity?.signupDate ? { signup_date: identity.signupDate } : undefined, + ); posthog.register({ ...baseSuperProps(), auth_status: "authenticated" }); } catch { // Analytics unavailable 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..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 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 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. @@ -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`, `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`)