Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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) {
Expand Down
53 changes: 34 additions & 19 deletions app/src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,10 @@ type AnalyticsProperty =
| "locale";

type Props = Partial<Record<AnalyticsProperty, string | number | boolean>>;
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<AnalyticsProperty>([
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions knowledge-base/data-rituals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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?)
Expand Down Expand 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.
Expand Down Expand Up @@ -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 [<your strategic accounts>]`
- **Power users** — top 10% by `total_messages_sent` (or `is_activated=true` if you haven't wired the counter yet)
Expand Down
8 changes: 4 additions & 4 deletions knowledge-base/production-infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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`)

Expand Down