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