feat(analytics): integrate PostHog for server-side event tracking

- Added a new PostHog client for capturing server-side business events such as Stripe webhooks and subscription state changes.
- Implemented various tracking functions for pricing funnel steps, character creation, and chat session starts.
- Enhanced the flux meter tests to handle partial charges and report unbilled flux correctly.
- Updated the CharacterDialog and Flux settings pages to track user interactions with analytics events.
- Introduced a mechanism to identify users on PostHog based on authentication state to ensure accurate funnel tracking.
- Added necessary dependencies for PostHog integration in the project.
This commit is contained in:
RainbowBird
2026-05-15 16:20:47 +08:00
parent bc7dda3d5f
commit 3984677b01
21 changed files with 1408 additions and 173 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { client } from '@proj-airi/stage-ui/composables/api'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Button, SelectTab } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@@ -12,6 +13,7 @@ const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { credits } = storeToRefs(authStore)
const { trackPricingViewed, trackPlanSelected, trackCheckoutStarted } = useAnalytics()
interface FluxPackage {
stripePriceId: string
@@ -221,6 +223,12 @@ async function fetchPackages() {
onMounted(async () => {
Promise.allSettled([fetchPackages(), authStore.updateCredits(), fetchStats(), fetchAuditHistory()])
// PostHog funnel step 1: pricing surface view. Today this is an in-app
// settings page (already-authenticated users); when we add a public
// pricing landing page the surface label changes but the event stays the
// same, so the funnel definition in PostHog doesn't need re-wiring.
trackPricingViewed('settings_flux', 'one_time')
if (route.query.success === 'true') {
message.value = { type: 'success', text: t('settings.pages.flux.checkout.success') }
router.replace({ query: {} })
@@ -234,6 +242,11 @@ onMounted(async () => {
async function handleBuy(stripePriceId: string) {
loadingPriceId.value = stripePriceId
message.value = null
// PostHog funnel step 2: user picked a plan. price_minor_unit lives on
// the Stripe webhook (server-side `payment_completed`); we deliberately
// don't send a formatted-string price from the SPA so funnels don't get
// poisoned by currency-formatting drift.
trackPlanSelected(stripePriceId, { currency: selectedCurrency.value })
try {
const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } })
if (!res.ok) {
@@ -243,6 +256,10 @@ async function handleBuy(stripePriceId: string) {
}
const data = await res.json()
if (data.url) {
// PostHog funnel step 3: about to redirect to Stripe. Capture before
// the page nav so the event is sent (PostHog's beforeunload handler
// would otherwise race the navigation).
trackCheckoutStarted(stripePriceId, { currency: selectedCurrency.value })
window.location.href = data.url
}
}
@@ -5,14 +5,16 @@ import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAnalytics } from '../../../../composables/use-analytics'
import { useBreakpoints } from '../../../../composables/use-breakpoints'
import { extractMessageText } from '../../../../libs/chat-sync'
import { useAuthStore } from '../../../../stores/auth'
import { useChatSessionStore } from '../../../../stores/chat/session-store'
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
/**
* Bottom-sheet (mobile) / centered-modal (desktop) UI surface that lists every
@@ -44,6 +46,16 @@ const chatSession = useChatSessionStore()
const { sessionMetas, sessionMessages, activeSessionId } = storeToRefs(chatSession)
const { activeCardId } = storeToRefs(useAiriCardStore())
const { userId } = storeToRefs(useAuthStore())
const { activeModel } = storeToRefs(useConsciousnessStore())
const { trackChatSessionStarted } = useAnalytics()
// Re-entry guard for the "new session" button. Without this, a rapid
// double-click would call `createSession` twice (creating two orphan
// sessions) and emit duplicate `chat_session_started` analytics events.
// The async `createSession` includes IndexedDB writes + a cloud reconcile
// kick-off, so even a single click can stay in flight long enough for a
// second click to slip through.
const isCreatingSession = ref(false)
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
@@ -147,9 +159,23 @@ async function selectSession(sessionId: string) {
}
async function startNewSession() {
const characterId = activeCardId.value || 'default'
await chatSession.createSession(characterId, { setActive: true })
showDialog.value = false
if (isCreatingSession.value)
return
isCreatingSession.value = true
try {
const characterId = activeCardId.value || 'default'
await chatSession.createSession(characterId, { setActive: true })
// PostHog retention denominator. We pick this call site (UI new-session
// button) rather than `createSession` in the store because the store also
// creates sessions for cloud-reconcile / fork / restore flows that aren't
// user-initiated. Model id is informational; sessionIndex is omitted
// (PostHog can compute it from per-user event ordering).
trackChatSessionStarted(activeModel.value || 'unknown')
showDialog.value = false
}
finally {
isCreatingSession.value = false
}
}
async function deleteRow(event: Event, sessionId: string) {
@@ -217,6 +243,7 @@ watch(showDialog, async (open) => {
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
'transition-colors',
]"
:disabled="isCreatingSession"
@click="startNewSession"
>
{{ t('stage.chat.sessions.new') }}
@@ -301,6 +328,7 @@ watch(showDialog, async (open) => {
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
'transition-colors',
]"
:disabled="isCreatingSession"
@click="startNewSession"
>
{{ t('stage.chat.sessions.new') }}
@@ -57,9 +57,128 @@ export function useAnalytics() {
})
}
/**
* Pricing funnel — step 1.
*
* Use when:
* - Any UI surface that shows Flux packages / subscription plans renders.
* Current surfaces: `settings_flux` (in-app billing settings). Future
* surfaces (a public pricing landing page, an upsell modal) just pass a
* different `surface` so the funnel split stays clean.
*
* Expects:
* - `surface` is a stable identifier — don't rename without coordinating
* PostHog funnel definitions in `docs/ai-context/metrics-ownership.md`.
*/
function trackPricingViewed(surface: string, planPeriod?: 'monthly' | 'annual' | 'one_time') {
if (!canCapture())
return
posthog.capture('pricing_page_viewed', { surface, ...(planPeriod && { plan_period: planPeriod }) })
}
/**
* Pricing funnel — step 2. Fires when the user picks a plan/package but
* hasn't yet kicked off the Stripe checkout redirect.
*/
function trackPlanSelected(planId: string, properties?: { price_minor_unit?: number, currency?: string }) {
if (!canCapture())
return
posthog.capture('plan_selected', { plan_id: planId, ...properties })
}
/**
* Pricing funnel — step 3. Fires right before redirecting to Stripe
* checkout (i.e. the SPA has the `checkout_session_id` and is about to
* `window.location.href = data.url`).
*
* Expects:
* - Caller awaits or fire-and-forgets this call immediately before
* `window.location.href = ...`. We pass `send_instantly: true` and
* `transport: 'sendBeacon'` so the event survives page navigation —
* the regular batched queue would race the redirect and drop the
* event, which breaks the funnel.
*
* The funnel terminator `payment_completed` is emitted server-side from
* the Stripe webhook — see `apps/server/src/routes/stripe/index.ts`.
*/
function trackCheckoutStarted(planId: string, properties: { checkout_session_id?: string, price_minor_unit?: number, currency?: string }) {
if (!canCapture())
return
posthog.capture(
'checkout_started',
{ plan_id: planId, ...properties },
{ send_instantly: true, transport: 'sendBeacon' },
)
}
/** Activation funnel — step 1. */
function trackSignup(method: 'email' | 'google' | 'github' | string) {
if (!canCapture())
return
posthog.capture('user_signed_up', { method })
}
/**
* Activation funnel — fires the first time a user picks a model in any
* provider settings. De-dup is intentional caller-side (we don't have a
* persistent "first model selected" flag yet); a small number of repeats
* is OK in PostHog funnels because step matching is per-distinctId, not
* per-event.
*/
function trackFirstModelSelected(modelId: string, provider: string) {
if (!canCapture())
return
posthog.capture('first_model_selected', { model_id: modelId, provider })
}
/** Retention driver — character creation is a strong D7 retention predictor. */
function trackCharacterCreated(properties: { character_type: 'built_in' | 'custom', voice_enabled: boolean }) {
if (!canCapture())
return
posthog.capture('character_created', properties)
}
/** Feature adoption — voice mode is a candidate retention lever; cohort comparisons live in PostHog. */
function trackVoiceModeActivated(characterId?: string) {
if (!canCapture())
return
posthog.capture('voice_mode_activated', characterId ? { character_id: characterId } : {})
}
/**
* Feature adoption — model switching frequency tells us whether
* routing/auto-pick changes are needed. Reason discriminates manual UI
* switch vs future auto-routing decisions.
*/
function trackModelSwitched(fromModel: string, toModel: string, reason: 'manual' | 'auto' = 'manual') {
if (!canCapture())
return
posthog.capture('model_switched', { from_model: fromModel, to_model: toModel, reason })
}
/**
* Retention cohort denominator — every chat session start. Pair with
* `payment_completed` cohort to compute "active paying user" retention
* curves in PostHog.
*/
function trackChatSessionStarted(modelId: string, sessionIndex?: number) {
if (!canCapture())
return
posthog.capture('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) })
}
return {
privacyPolicyUrl,
trackProviderClick,
trackFirstMessage,
trackPricingViewed,
trackPlanSelected,
trackCheckoutStarted,
trackSignup,
trackFirstModelSelected,
trackCharacterCreated,
trackVoiceModeActivated,
trackModelSwitched,
trackChatSessionStarted,
}
}
@@ -4,10 +4,15 @@ import { defineStore, storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useBuildInfo } from '../../composables/use-build-info'
import { useAuthStore } from '../auth'
import { useConsciousnessStore } from '../modules/consciousness'
import { useSettingsAnalytics } from '../settings/analytics'
import {
capturePosthogEvent,
identifyPosthogUser,
isPosthogAvailableInBuild,
registerPosthogBuildInfo,
resetPosthog,
syncPosthogCapture,
} from './posthog'
@@ -22,6 +27,11 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
const appStartTime = ref<number | null>(null)
const firstMessageTracked = ref(false)
// In-memory only, intentionally — matches `firstMessageTracked` semantics
// (resets on reload). PostHog can compute true "first time across all
// sessions" with `posthog.capture('first_*', ..., { send_instantly: true })`
// + person-level dedup at query time.
const firstModelSelectedTracked = ref(false)
watch(analyticsEnabled, (enabled, previousEnabled) => {
if (!isInitialized.value)
@@ -38,6 +48,15 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
}
registerPosthogBuildInfo(buildInfo.value)
// If a user enabled analytics mid-session while already authenticated,
// identify them now — `initialize()`'s identify only fires once at
// app startup and at auth-state changes, neither of which trigger
// on a delayed opt-in. Without this, server-side `payment_completed`
// (keyed by Better Auth user id) won't merge with the browser's
// anonymous funnel events.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyPosthogUser(authStore.user.id)
}
})
@@ -53,6 +72,85 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
registerPosthogBuildInfo(buildInfo.value)
}
// Wire PostHog identity to auth state. Without this server-side events
// (`payment_completed` keyed on Better Auth `user.id`) and browser-side
// funnel events (anonymous `distinct_id` until identify) live on
// different person profiles and the funnel never joins. See
// `apps/server/docs/ai-context/metrics-ownership.md`.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyPosthogUser(authStore.user.id)
authStore.onAuthenticated(() => {
if (authStore.user?.id)
identifyPosthogUser(authStore.user.id)
})
authStore.onLogout(() => {
resetPosthog()
})
// Wire model-selection events. The consciousness store holds the
// user-chosen chat model; both `activeProvider` and `activeModel` are
// persisted via `useLocalStorageManualReset`, so on app load this
// watcher fires once with the restored value as the "new" half (oldVal
// is undefined). We guard on `oldProvider == null` to treat the boot
// case as a baseline, not as a switch — otherwise every page load
// would emit `model_switched`.
//
// Single `model_switched` callsite by design: consciousness reads/writes
// happen across many UI surfaces (onboarding step, settings page,
// model picker dropdown). Centralising the event here means new model-
// change UI doesn't need to remember to fire analytics.
const consciousness = useConsciousnessStore()
watch(
() => ({ provider: consciousness.activeProvider, model: consciousness.activeModel }),
(next, prev) => {
if (!next.provider || !next.model)
return
// Baseline on first watcher tick (oldVal undefined when the watcher
// mounts with already-restored localStorage state).
if (!prev) {
if (!firstModelSelectedTracked.value) {
// User has a model picked from a prior session — count it as
// their first observed selection, but don't emit `model_switched`
// since we have nothing to switch from. Only flip the dedup flag
// when the capture actually went out (PostHog initialised + user
// not opted out); otherwise an early opt-in or delayed init
// would never get the chance to emit `first_model_selected`.
const captured = capturePosthogEvent('first_model_selected', { model_id: next.model, provider: next.provider })
if (captured)
firstModelSelectedTracked.value = true
}
return
}
if (prev.provider === next.provider && prev.model === next.model)
return
if (!firstModelSelectedTracked.value) {
// Same gating as the baseline branch: only mark first-selection
// as tracked when capture actually shipped.
const captured = capturePosthogEvent('first_model_selected', { model_id: next.model, provider: next.provider })
if (captured)
firstModelSelectedTracked.value = true
return
}
// Genuine switch — emit only when we have a meaningful "from" model.
// Provider transitions without a prior model (e.g. user clears then
// re-selects) skip the switch event; the next clean A → B will fire.
if (prev.model) {
capturePosthogEvent('model_switched', {
from_model: prev.model,
to_model: next.model,
reason: 'manual',
})
}
},
{ immediate: true },
)
isInitialized.value = true
}
@@ -73,3 +73,60 @@ export function registerPosthogBuildInfo(buildInfo: AboutBuildInfo): void {
app_build_time: buildInfo.builtOn,
})
}
/**
* Identify the current user on PostHog so server-side `payment_completed` /
* `subscription_cancelled` events (which use the Better Auth user id as
* `distinctId`) merge with the same person profile as the browser's
* anonymous funnel start events. Without this call the funnel is broken
* end-to-end: server events land on the user-id person, browser events
* land on the anonymous device person, PostHog cannot join them.
*
* Expects:
* - `userId` is the Better Auth user id (`user.id`) — must match what
* `apps/server/src/routes/stripe/index.ts` passes as `distinctId` in
* `capturePaymentCompleted`.
*/
export function identifyPosthogUser(userId: string): void {
if (!posthogInitialized || posthog.has_opted_out_capturing())
return
// PostHog's `identify` is idempotent and aliases the anonymous distinct
// id, so calling it on every auth-state-change is safe.
posthog.identify(userId)
}
/**
* Reset PostHog's distinct id on logout so subsequent activity from this
* device is treated as a new anonymous user (not attributed to the prior
* logged-in user, which would corrupt cohort analysis if a second user
* signs in on the same device).
*/
export function resetPosthog(): void {
if (!posthogInitialized)
return
posthog.reset()
}
interface PosthogCaptureOptions {
send_instantly?: boolean
transport?: 'XHR' | 'fetch' | 'sendBeacon'
}
/**
* Single source-of-truth wrapper for emitting events from store-layer code
* (places that can't pull `useAnalytics()` without creating circular
* `analytics-store → use-analytics composable → analytics-store` graphs).
* Returns `false` when capture was skipped so callers can gate dedup flags.
*
* Use when:
* - You're inside a pinia store / Vue watcher that needs to fire a PostHog
* event. UI components should still prefer `useAnalytics()` composable
* for consistency with existing call sites.
*/
export function capturePosthogEvent(name: string, properties: Record<string, unknown>, options?: PosthogCaptureOptions): boolean {
if (!posthogInitialized || posthog.has_opted_out_capturing())
return false
posthog.capture(name, properties, options)
return true
}