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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user