feat(analytics): expand PostHog coverage and forward server business facts (#2038)

## What

Closes the three structural gaps in AIRI's product analytics: the signup
surface had zero instrumentation, the payment funnel had no terminator
in PostHog, and SPA route changes emitted no pageviews. Also adds
semantic events for character cards, desktop-only features, and
data-maintenance actions.

## User paths

- User signs up / logs in / verifies email / resets password / links
OAuth / deletes account → each step now emits a PostHog event from
`apps/ui-server-auth` (previously fully uninstrumented), with
`identify()` wired on session load so anonymous funnel events merge into
the user person.
- User pays via Stripe → webhook writes `product_events` as before, and
the product-events service now forwards `payment_completed` (plus signup
and subscription lifecycle facts) to PostHog via posthog-node
`captureImmediate`, keyed by the Better Auth user id → the
`checkout_started → payment_completed` funnel closes end-to-end.
Per-request LLM/TTS volume is explicitly not forwarded.
- User navigates between routes in any surface (web / desktop / pocket /
docs) → `$pageview` + `$pageleave` fire per route change via the
posthog-js `defaults: '2025-05-24'` preset in the shared
`posthog.config.ts`.
- User creates / imports / duplicates / edits a ccv3 card, switches
stage background, runs destructive data actions (export / import / clear
chats, reset providers, wipe app data), or uses desktop differentiators
(Spotlight send, widget windows, in-app updater, MCP server management,
pairing QR) → dedicated low-cardinality events.

## Notable decisions

- Server forwarding defaults on: `POSTHOG_PROJECT_KEY` defaults to the
shared browser-safe phc_* project key; set it to an empty string to
disable. Postgres `product_events` remains the source of truth.
- Cross-surface events (`oauth_callback_failed`, account lifecycle)
share one stage vocabulary exported from stage-ui so the two emitters
cannot drift silently.
- Events captured right before full-page navigation use `sendBeacon` so
they survive the redirect (checkout, OAuth consent handoff, login
redirect).
- Removed dead wrappers (`trackSignup`, `trackFirstModelSelected`,
`trackModelChanged`) that duplicated live event streams under second
names.

## How tested

- `pnpm -F @proj-airi/server exec vitest run
src/services/domain/product-events.test.ts` — 6 passed, covering the
forwarding allowlist, the `user_signed_up → signup_completed` mapping,
non-forwarded per-request actions, and a throwing sink not failing the
webhook path nor losing the DB row.
- stage-ui suites (`use-analytics`, `use-linked-accounts`, exports
contract) — 22 passed, including new account/card/data/desktop event
assertions.
- Real transport smoke: posthog-node `captureImmediate` against
`us.i.posthog.com` with the production key resolved in 1380ms (one
`server_forwarding_smoke_test` event left in the project; filter by
event name).
- Browser-tested pageviews: `VITE_ENABLE_POSTHOG=true` dev build, two
`history.pushState` route changes each produced a `$pageview` with
`$pathname`, `navigation_type: pushState`, previous-page dwell time, and
the `surface` super property; batched POST to `us.i.posthog.com/e/`
returned 200. Note: posthog-js drops events from automated browsers
(`navigator.webdriver`) by default — the verification session bypassed
the bot filter locally; production config is untouched.
- Typecheck and lint pass for server, stage-ui, stage-pages, stage-web,
stage-tamagotchi, ui-server-auth.

Full verification record:
`apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md`

## Follow-ups (not in this PR)

- Bot channel usage stats (Discord / Telegram) once they route through
server-runtime counters.
- Main-process desktop events (tray menu, global shortcut fire) need
renderer relay plumbing.
- Confirm `payment_completed` arrives in PostHog after the first real
Stripe payment post-deploy.

https://claude.ai/code/session_01Q1yGavkQ1P41YhTWE4XKex
This commit is contained in:
RainbowBird
2026-07-08 15:33:39 +08:00
committed by GitHub
parent 5aa44aedd3
commit 0642ede8d1
46 changed files with 1133 additions and 92 deletions
+3
View File
@@ -14,6 +14,7 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { initAuthAnalytics } from './modules/analytics'
import { AUTH_UI_ROUTER_BASE_PATH } from './modules/auth-ui-base'
import { i18n } from './modules/i18n'
@@ -23,6 +24,8 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
@@ -0,0 +1,167 @@
/**
* PostHog product analytics for the auth-only SPA (`apps/ui-server-auth`).
*
* This surface is the top of the activation funnel: sign-up, sign-in, email
* verification, password recovery. Events captured here are the funnel
* entry that the in-app surfaces (`signup_completed → onboarding_started →
* first message`) join against, keyed by the Better Auth user id passed to
* {@link identifyAuthUser} — the same id the server uses as `distinctId`
* for its own events, so the person profiles merge.
*
* Unlike the stage apps there is no in-app analytics consent toggle here
* (the user isn't signed in yet, so there's no settings store to read).
* Capture posture matches the docs site: enabled in analytics-enabled
* builds (`VITE_ENABLE_POSTHOG`), disclosed via the privacy policy linked
* on the sign-in page.
*/
import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables'
import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY,
} from '../../../../posthog.config'
/** Login/signup credential kinds shown on the sign-in page. */
export type AuthMethod = 'email' | 'github' | 'google'
let initialized = false
/**
* Initialize PostHog for the auth surface. Call once from `main.ts` before
* mount; later calls are no-ops. Returns whether capture is active so
* callers can skip building event payloads in analytics-disabled builds.
*/
export function initAuthAnalytics(): boolean {
if (!POSTHOG_ENABLED)
return false
if (initialized)
return true
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// Same single-project setup as the stage apps: the `surface` super
// property is how auth traffic is told apart in shared dashboards.
posthog.register({ surface: 'auth' })
initialized = true
return true
}
/**
* Merge this browser's anonymous events with the Better Auth user person.
* `userId` must be the Better Auth `user.id` — the same value the server
* uses as `distinctId` (see `apps/server` product events forwarding).
*/
export function identifyAuthUser(userId: string): void {
if (!initialized)
return
posthog.identify(userId)
}
interface CaptureOptions {
/**
* Set when navigation immediately follows the capture call
* (`window.location.href = ...`). The batched queue would race the
* unload and drop the event; sendBeacon survives it.
*/
beforeNavigation?: boolean
}
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (!initialized)
return
posthog.capture(
event,
properties,
options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined,
)
}
/** Activation funnel step 1 — the account now exists (email flow). */
export function trackSignupCompleted(properties: { source: AuthMethod, requires_verification: boolean }): void {
capture('signup_completed', properties, { beforeNavigation: !properties.requires_verification })
}
/**
* OAuth flows leave the page before their outcome is knowable, so the
* client can only record the attempt; completion shows up as the
* identified session on the callback landing.
*/
export function trackLoginStarted(properties: { method: AuthMethod }): void {
capture('login_started', properties, { beforeNavigation: true })
}
/** Credential sign-in succeeded; OIDC continuation navigation follows. */
export function trackLoginSucceeded(properties: { method: AuthMethod }): void {
capture('login_succeeded', properties, { beforeNavigation: true })
}
/**
* Sign-in attempt failed. No error detail on purpose — auth error messages
* can embed the email address, and the count per method is what the funnel
* needs.
*/
export function trackLoginFailed(properties: { method: AuthMethod }): void {
capture('login_failed', properties)
}
/** Verification link landing with `?verified=true`. */
export function trackEmailVerificationCompleted(): void {
capture('email_verification_completed', {})
}
/** Verification link landing with `?error=...`. */
export function trackEmailVerificationFailed(): void {
capture('email_verification_failed', {})
}
export function trackPasswordResetRequested(): void {
capture('password_reset_requested', {})
}
export function trackPasswordResetCompleted(): void {
capture('password_reset_completed', {})
}
export function trackPasswordChanged(): void {
capture('password_changed', {})
}
/**
* Link handed off to the provider's consent page. Completion is not
* client-observable (it lands back via a full-page OAuth redirect), so the
* funnel pairs this with the refreshed linked-accounts state server-side.
*/
export function trackOauthProviderLinkStarted(properties: { provider: string }): void {
capture('oauth_provider_link_started', properties, { beforeNavigation: true })
}
export function trackOauthProviderUnlinked(properties: { provider: string }): void {
capture('oauth_provider_unlinked', properties)
}
/**
* Deletion-confirmed landing page reached (`delete-account.vue`). The
* deletion request itself is raised from the stage apps' account settings.
*/
export function trackAccountDeletionCompleted(): void {
capture('account_deletion_completed', {})
}
export function trackSignedOut(): void {
capture('signed_out', {})
}
/**
* Electron OIDC relay handoff failed. `stage` distinguishes a malformed
* callback (`parse`) from an unreachable local app (`relay_unreachable`);
* the full cross-surface vocabulary lives in stage-ui's
* `OauthCallbackFailureStage` so the two emitters share one schema.
*/
export function trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
capture('oauth_callback_failed', properties)
}
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { trackAccountDeletionCompleted } from '../modules/analytics'
// NOTICE:
// This page is a SUCCESS landing — better-auth's `/api/auth/delete-user/callback`
// performs the actual deletion server-side, then redirects here via the
@@ -28,6 +30,14 @@ const errorMessage = computed(() => {
const value = route.query.error
return typeof value === 'string' ? value : null
})
onMounted(() => {
// Strongest churn fact the client can observe: better-auth only
// redirects here after the deletion callback already ran server-side,
// so rendering the success state means the account is gone.
if (!errorMessage.value)
trackAccountDeletionCompleted()
})
</script>
<template>
@@ -4,6 +4,7 @@ import { onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseElectronCallbackQuery } from '../composables/electron-callback.shared'
import { trackOauthCallbackFailed } from '../modules/analytics'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
type CallbackStatus = 'loading' | 'success' | 'fallback' | 'error'
@@ -55,6 +56,7 @@ async function runRelayFlow() {
const parsed = parseElectronCallbackQuery(query)
if (parsed.status === 'error') {
trackOauthCallbackFailed({ stage: 'parse' })
setViewModel({
description: t('server.auth.electronCallback.message.invalidResponse'),
detail: parsed.message,
@@ -98,6 +100,7 @@ async function runRelayFlow() {
}, 1200)
}
catch {
trackOauthCallbackFailed({ stage: 'relay_unreachable' })
setViewModel({
description: t('server.auth.electronCallback.message.loopbackUnreachable'),
detail: t('server.auth.electronCallback.message.tryOpenDirectly'),
@@ -4,6 +4,7 @@ import { Button, FieldInput } from '@proj-airi/ui'
import { reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { trackPasswordResetRequested } from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { describeAuthError, requestPasswordReset } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@@ -36,6 +37,7 @@ async function handleSubmit(event: Event) {
email: form.email.trim(),
redirectTo: resetRedirect,
})
trackPasswordResetRequested()
submitted.value = true
}
catch (error) {
+16
View File
@@ -9,6 +9,14 @@ import { computed, onMounted, reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
identifyAuthUser,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackPasswordChanged,
trackPasswordResetRequested,
trackSignedOut,
} from '../modules/analytics'
import { getAuthClient } from '../modules/auth-client'
import { requestPasswordReset } from '../modules/email-password'
import {
@@ -98,6 +106,8 @@ const {
unlinked: provider => t('server.auth.profile.linkedAccounts.message.unlinked', { provider }),
linkStarted: provider => t('server.auth.profile.linkedAccounts.message.linkStarted', { provider }),
},
onUnlinked: providerId => trackOauthProviderUnlinked({ provider: providerId }),
onLinkStarted: providerId => trackOauthProviderLinkStarted({ provider: providerId }),
})
const nameDirty = computed(() => {
@@ -138,6 +148,9 @@ onMounted(async () => {
// call needed here.
user.value = result.user
profileForm.name = result.user.name
// Merge this browser's anonymous funnel events (sign-in page views,
// login_started, …) into the Better Auth user person.
identifyAuthUser(result.user.id)
}
catch (error) {
profileError.value = describeProfileError(error) || t('server.auth.profile.error.loadFailed')
@@ -200,6 +213,7 @@ async function handleChangePassword(event: Event) {
passwordForm.next = ''
passwordForm.confirm = ''
passwordSuccess.value = t('server.auth.profile.message.passwordChanged')
trackPasswordChanged()
}
catch (error) {
passwordError.value = describeProfileError(error) || t('server.auth.profile.error.changePasswordFailed')
@@ -237,6 +251,7 @@ async function handleSendSetPasswordLink() {
redirectTo: new URL('/auth/reset-password', apiServerUrl).toString(),
})
setPasswordSuccess.value = t('server.auth.profile.password.setLinkSent', { email: user.value.email })
trackPasswordResetRequested()
}
catch (error) {
setPasswordError.value = describeProfileError(error)
@@ -256,6 +271,7 @@ async function handleSignOut() {
try {
await signOut({ apiServerUrl })
trackSignedOut()
await router.replace('/sign-in')
}
catch (error) {
@@ -5,6 +5,7 @@ import { computed, reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { trackPasswordResetCompleted } from '../modules/analytics'
import { describeAuthError, resetPasswordWithToken } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@@ -52,6 +53,7 @@ async function handleSubmit(event: Event) {
newPassword: form.password,
token: token.value,
})
trackPasswordResetCompleted()
completed.value = true
}
catch (error) {
+12
View File
@@ -8,6 +8,12 @@ import { computed, reactive, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
trackLoginFailed,
trackLoginStarted,
trackLoginSucceeded,
trackSignupCompleted,
} from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import {
checkEmail,
@@ -127,9 +133,11 @@ async function handleProviderSelect(provider: OAuthProvider) {
callbackURL: effectiveCallbackURL.value,
})
trackLoginStarted({ method: provider })
window.location.href = redirectUrl
}
catch (error) {
trackLoginFailed({ method: provider })
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
pendingProvider.value = null
}
@@ -202,9 +210,11 @@ async function handleEmailSignIn(event: Event) {
// After a successful credential sign-in better-auth has set the session
// cookie. Bounce into the OIDC `/oauth2/authorize` flow (or wherever the
// OIDC client originally pointed) so the upstream stage app gets its tokens.
trackLoginSucceeded({ method: 'email' })
window.location.href = result.redirectURL ?? effectiveCallbackURL.value
}
catch (error) {
trackLoginFailed({ method: 'email' })
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
}
finally {
@@ -237,6 +247,7 @@ async function handleEmailSignUp(event: Event) {
})
if (result.requiresVerification) {
trackSignupCompleted({ source: 'email', requires_verification: true })
await router.push({
path: '/verify-email',
query: {
@@ -249,6 +260,7 @@ async function handleEmailSignUp(event: Event) {
// Verification disabled at server config: session is live, fall through
// to the OIDC continuation just like sign-in.
trackSignupCompleted({ source: 'email', requires_verification: false })
window.location.href = effectiveCallbackURL.value
}
catch (error) {
@@ -5,6 +5,7 @@ import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { trackEmailVerificationCompleted, trackEmailVerificationFailed } from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@@ -87,13 +88,16 @@ onMounted(async () => {
// session cookie has been written, then stay put so the user sees the
// success message. The pending tab does the OIDC continuation.
if (verified.value) {
trackEmailVerificationCompleted()
if (isSupported.value)
post('verified')
return
}
if (error.value)
if (error.value) {
trackEmailVerificationFailed()
return
}
// Pending tab: cover the case where verification already happened before
// this tab subscribed (back-button navigation, page reload, etc.). One