fix(stage-ui,stage-pocket,stage-web,stage-tamagotchi,ui-server-auth): analytics import not deferred or lazied

This commit is contained in:
Neko Ayaka
2026-07-30 14:33:21 +08:00
parent fe470ff81c
commit 1927e54c9c
29 changed files with 793 additions and 506 deletions
@@ -112,7 +112,7 @@
### PostHog(前端 / 外部数据源,产品侧)
已接入:
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 动态 adapter 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。
- Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHogLLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。
- 前端 identity`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
- 平台统一写入 `app_surface``entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
@@ -108,7 +108,7 @@ function handleSetupSkipped() {
const extraSteps = computed(() => [
...(
isPosthogAvailableInBuild()
isAnalyticsAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
),
+6
View File
@@ -7,6 +7,7 @@ import NProgress from 'nprogress'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -27,6 +28,11 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
return createPosthogAdapter(options)
})
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
@@ -6,6 +6,7 @@ import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -37,6 +38,11 @@ import '@fontsource/kiwi-maru/index.css'
import '@fontsource/m-plus-rounded-1c/index.css'
import '@fontsource-variable/nunito/index.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
return createPosthogAdapter(options)
})
const pinia = createPinia()
const router = createRouter({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { OnboardingScreen, OnboardingStepAnalyticsNotice } from '@proj-airi/stage-ui/components'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useTheme } from '@proj-airi/ui'
@@ -30,7 +30,7 @@ watch(needsLogin, async (val) => {
const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')
const extraSteps = computed(() => {
return isPosthogAvailableInBuild()
return isAnalyticsAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
})
+2 -2
View File
@@ -2,7 +2,7 @@
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
@@ -65,7 +65,7 @@ const colors = computed(() => {
})
const onboardingExtraSteps = computed(() => {
return isPosthogAvailableInBuild()
return isAnalyticsAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
})
+6
View File
@@ -8,6 +8,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -27,6 +28,11 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
return createPosthogAdapter(options)
})
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
+17
View File
@@ -91,6 +91,23 @@ export default defineConfig({
},
},
build: {
manifest: true,
rolldownOptions: {
output: {
chunkFileNames: (chunkInfo) => {
const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => {
const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase()
return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog')
})
// Only analytics/provider chunks receive the manual neutral mapping;
// all unrelated chunks retain Vite's readable default naming.
return containsAnalyticsModule
? 'assets/auxiliary-[hash].js'
: 'assets/[name]-[hash].js'
},
},
},
sourcemap: true,
},
worker: {
+7 -2
View File
@@ -14,7 +14,7 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { initAuthAnalytics } from './modules/analytics'
import { loadAnalyticsAdapter } from './modules/analytics'
import { AUTH_UI_ROUTER_BASE_PATH } from './modules/auth-ui-base'
import { i18n } from './modules/i18n'
@@ -24,7 +24,12 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
if (isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)) {
void loadAnalyticsAdapter(async () => {
const { createPosthogAdapter } = await import('./modules/analytics-adapters/posthog')
return createPosthogAdapter()
})
}
const pinia = createPinia()
@@ -0,0 +1,28 @@
import type { AnalyticsAdapter } from '../analytics'
import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_PROJECT_KEY,
} from '../../../../../posthog.config'
/** Creates the auth analytics adapter and initializes its provider SDK. */
export function createPosthogAdapter(): AnalyticsAdapter {
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// The shared project distinguishes auth traffic through this super property.
posthog.register({ app_surface: 'auth' })
return {
capture(event, properties, options) {
posthog.capture(
event,
properties,
options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined,
)
},
identify(userId) {
posthog.identify(userId)
},
}
}
@@ -1,28 +1,22 @@
import type { AnalyticsAdapter } from './analytics'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { initAuthAnalytics, trackSignupFormCompleted } from './analytics'
import {
AnalyticsClient,
loadAnalyticsAdapter,
trackSignupFormCompleted,
} from './analytics'
const posthogMocks = vi.hoisted(() => ({
const adapterMocks = {
capture: vi.fn(),
init: vi.fn(),
register: vi.fn(),
}))
identify: vi.fn(),
} satisfies AnalyticsAdapter
vi.mock('posthog-js', () => ({
default: posthogMocks,
}))
vi.mock('../../../../posthog.config', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_ENABLED: true,
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
describe('auth product analytics', () => {
describe('auth analytics', () => {
beforeEach(() => {
posthogMocks.capture.mockClear()
posthogMocks.init.mockClear()
posthogMocks.register.mockClear()
adapterMocks.capture.mockClear()
adapterMocks.identify.mockClear()
})
// ROOT CAUSE:
@@ -33,16 +27,49 @@ describe('auth product analytics', () => {
//
// The anonymous UI milestone must use its own name. The identified server
// event remains the only canonical `signup_completed` business fact.
it('keeps anonymous signup UI completion separate from the canonical server signup fact', () => {
expect(initAuthAnalytics()).toBe(true)
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'auth' })
it('keeps anonymous signup UI completion separate from the canonical server signup fact', async () => {
await expect(loadAnalyticsAdapter(async () => adapterMocks)).resolves.toBe(true)
trackSignupFormCompleted({ source: 'email', requires_verification: true })
expect(posthogMocks.capture).toHaveBeenCalledWith(
expect(adapterMocks.capture).toHaveBeenCalledWith(
'signup_form_completed',
{ source: 'email', requires_verification: true },
undefined,
{ beforeNavigation: false },
)
})
it('flushes calls made while the optional adapter is loading', async () => {
const client = new AnalyticsClient()
let install: ((adapter: AnalyticsAdapter) => void) | undefined
const loading = client.load(() => new Promise<AnalyticsAdapter>((resolve) => {
install = resolve
}))
client.capture('login_started', { method: 'github' }, { beforeNavigation: true })
client.identify('user-1')
await Promise.resolve()
install?.(adapterMocks)
await expect(loading).resolves.toBe(true)
expect(adapterMocks.capture).toHaveBeenCalledWith(
'login_started',
{ method: 'github' },
{ beforeNavigation: true },
)
expect(adapterMocks.identify).toHaveBeenCalledWith('user-1')
})
it('becomes a harmless no-op when a content blocker rejects the adapter import', async () => {
const client = new AnalyticsClient()
const loading = client.load(async () => {
throw new TypeError('Failed to fetch dynamically imported module')
})
client.capture('login_started', { method: 'google' })
await expect(loading).resolves.toBe(false)
expect(() => client.capture('login_failed', { method: 'google' })).not.toThrow()
expect(adapterMocks.capture).not.toHaveBeenCalled()
})
})
+105 -47
View File
@@ -1,5 +1,5 @@
/**
* PostHog product analytics for the auth-only SPA (`apps/ui-server-auth`).
* Provider-neutral analytics for the auth-only SPA (`apps/ui-server-auth`).
*
* This surface captures anonymous auth-UI milestones such as form completion,
* sign-in attempts, email verification, and password recovery. Canonical
@@ -9,44 +9,120 @@
*
* 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.
* Capture posture matches the docs site: the optional provider is enabled by
* the application entry in configured builds and 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
interface CaptureOptions {
/**
* Set when navigation immediately follows capture. Adapters can select a
* transport that survives document unload.
*/
beforeNavigation?: boolean
}
/** Adapter contract installed by an optional analytics provider chunk. */
export interface AnalyticsAdapter {
capture: (event: string, properties: Record<string, unknown>, options?: CaptureOptions) => void
identify: (userId: string) => void
}
type PendingOperation
= | { kind: 'capture', event: string, properties: Record<string, unknown>, options?: CaptureOptions }
| { kind: 'identify', userId: string }
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
/**
* 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.
* Owns optional-adapter loading and guarantees that product-event calls never
* make core auth UI wait for, or depend on, a provider SDK.
*/
export function initAuthAnalytics(): boolean {
if (!POSTHOG_ENABLED)
return false
export class AnalyticsClient {
private adapter: AnalyticsAdapter | undefined
private loadPromise: Promise<boolean> | undefined
private loadState: LoadState = 'idle'
private readonly pendingOperations: PendingOperation[] = []
if (initialized)
return true
load(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
if (this.loadPromise)
return this.loadPromise
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// Same single-project setup as the stage apps: the `app_surface` super
// property is how auth traffic is told apart in shared dashboards.
posthog.register({ app_surface: 'auth' })
initialized = true
return true
this.loadState = 'loading'
this.loadPromise = Promise.resolve()
.then(loader)
.then((adapter) => {
this.adapter = adapter
this.loadState = 'ready'
this.flush()
return true
})
.catch(() => {
// Content blockers commonly reject the provider's module request. The
// provider is optional, so discard queued telemetry and stay no-op.
this.pendingOperations.length = 0
this.loadState = 'unavailable'
return false
})
return this.loadPromise
}
capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (this.adapter) {
this.adapter.capture(event, properties, options)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'capture', event, properties, options })
}
identify(userId: string): void {
if (this.adapter) {
this.adapter.identify(userId)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'identify', userId })
}
private enqueue(operation: PendingOperation): void {
// A provider may remain slow indefinitely. Bound memory while preserving
// the newest auth funnel steps, which are the most useful after recovery.
if (this.pendingOperations.length === 100)
this.pendingOperations.shift()
this.pendingOperations.push(operation)
}
private flush(): void {
if (!this.adapter)
return
for (const operation of this.pendingOperations) {
if (operation.kind === 'identify')
this.adapter.identify(operation.userId)
else
this.adapter.capture(operation.event, operation.properties, operation.options)
}
this.pendingOperations.length = 0
}
}
const analytics = new AnalyticsClient()
/**
* Starts loading the optional provider adapter without exposing its SDK to
* pages or to the application's static module graph.
*/
export function loadAnalyticsAdapter(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
return analytics.load(loader)
}
/**
@@ -55,29 +131,11 @@ export function initAuthAnalytics(): boolean {
* 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
analytics.identify(userId)
}
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,
)
analytics.capture(event, properties, options)
}
/** Anonymous email-signup UI milestone; the server owns the registration fact. */
+13 -9
View File
@@ -45,18 +45,22 @@ export default defineConfig({
},
build: {
emptyOutDir: true,
manifest: true,
outDir: resolve(join(import.meta.dirname, 'dist')),
rolldownOptions: {
output: {
// NOTICE:
// Safari content blockers can reject application chunks solely because a
// semantic filename such as `analytics-*.js` matches a filtering rule.
// Root cause: auth analytics is currently shared by the entry and routes,
// so blocking that generated chunk prevents the Vue app from mounting.
// Source/context: `apps/ui-server-auth/src/modules/analytics.ts`.
// Removal condition: analytics is no longer an application-critical static
// dependency and blocked optional modules cannot prevent app startup.
chunkFileNames: 'assets/chunk-[hash].js',
chunkFileNames: (chunkInfo) => {
const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => {
const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase()
return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog')
})
// Keep analytics as the source-domain name, but explicitly map its
// public URL to a neutral chunk name that filter lists cannot infer.
return containsAnalyticsModule
? 'assets/chunk-[hash].js'
: 'assets/[name]-[hash].js'
},
},
},
sourcemap: true,
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldCombobox, useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
@@ -16,7 +16,7 @@ const props = withDefaults(defineProps<{
const settings = useSettings()
const showControlsIsland = computed(() => props.needsControlsIslandIconSizeSetting)
const showAnalyticsSettings = computed(() => isPosthogAvailableInBuild())
const showAnalyticsSettings = computed(() => isAnalyticsAvailableInBuild())
const analyticsToggleValue = computed({
get: () => showAnalyticsSettings.value ? settings.analyticsEnabled : false,
set: (value: boolean) => settings.analyticsEnabled = value,
+1
View File
@@ -35,6 +35,7 @@
"./tools/mcp": "./src/tools/mcp.ts",
"./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts",
"./stores/analytics": "./src/stores/analytics/index.ts",
"./stores/analytics/client": "./src/stores/analytics/client.ts",
"./stores/analytics/posthog": "./src/stores/analytics/posthog.ts",
"./stores/analytics/privacy-policy": "./src/stores/analytics/privacy-policy.ts",
"./stores/character": "./src/stores/character/index.ts",
@@ -4,10 +4,10 @@ import { ref } from 'vue'
import { useAnalytics } from './use-analytics'
const analyticsMocks = vi.hoisted(() => ({
ensurePosthogInitializedMock: vi.fn(() => true),
ensureAnalyticsInitializedMock: vi.fn(() => true),
isStageCapacitorMock: vi.fn(() => false),
isStageTamagotchiMock: vi.fn(() => false),
isPosthogAvailableInBuildMock: vi.fn(() => true),
isAnalyticsAvailableInBuildMock: vi.fn(() => true),
markFirstMessageTrackedMock: vi.fn(),
posthogCaptureMock: vi.fn(),
}))
@@ -17,12 +17,6 @@ vi.mock('@proj-airi/stage-shared', () => ({
isStageTamagotchi: analyticsMocks.isStageTamagotchiMock,
}))
vi.mock('posthog-js', () => ({
default: {
capture: analyticsMocks.posthogCaptureMock,
},
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: ref('en'),
@@ -37,9 +31,10 @@ vi.mock('../stores/analytics', () => ({
}),
}))
vi.mock('../stores/analytics/posthog', () => ({
ensurePosthogInitialized: analyticsMocks.ensurePosthogInitializedMock,
isPosthogAvailableInBuild: analyticsMocks.isPosthogAvailableInBuildMock,
vi.mock('../stores/analytics/client', () => ({
captureAnalyticsEvent: analyticsMocks.posthogCaptureMock,
ensureAnalyticsInitialized: analyticsMocks.ensureAnalyticsInitializedMock,
isAnalyticsAvailableInBuild: analyticsMocks.isAnalyticsAvailableInBuildMock,
}))
vi.mock('../stores/analytics/privacy-policy', () => ({
@@ -62,12 +57,12 @@ describe('useAnalytics conversation product events', () => {
beforeEach(() => {
analyticsMocks.posthogCaptureMock.mockClear()
analyticsMocks.markFirstMessageTrackedMock.mockClear()
analyticsMocks.ensurePosthogInitializedMock.mockClear()
analyticsMocks.ensureAnalyticsInitializedMock.mockClear()
analyticsMocks.isStageCapacitorMock.mockReset()
analyticsMocks.isStageTamagotchiMock.mockReset()
analyticsMocks.isStageCapacitorMock.mockReturnValue(false)
analyticsMocks.isStageTamagotchiMock.mockReturnValue(false)
analyticsMocks.isPosthogAvailableInBuildMock.mockClear()
analyticsMocks.isAnalyticsAvailableInBuildMock.mockClear()
})
it('uses app_surface for the web runtime without occupying the event entry surface', () => {
@@ -513,10 +508,7 @@ describe('useAnalytics conversation product events', () => {
currency: 'USD',
entry_surface: 'settings_flux',
plan_id: 'price-1',
}, {
send_instantly: true,
transport: 'sendBeacon',
})
}, { beforeNavigation: true })
})
/**
@@ -938,7 +930,7 @@ describe('useAnalytics conversation product events', () => {
3,
'oauth_provider_link_started',
{ app_surface: 'web', provider: 'github' },
{ send_instantly: true, transport: 'sendBeacon' },
{ beforeNavigation: true },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
app_surface: 'web',
@@ -1012,7 +1004,7 @@ describe('useAnalytics conversation product events', () => {
action: 'refresh_window',
app_surface: 'electron',
},
{ send_instantly: true, transport: 'sendBeacon' },
{ beforeNavigation: true },
)
})
@@ -1030,7 +1022,7 @@ describe('useAnalytics conversation product events', () => {
analytics.trackMcpConnectionTestRun({ success: false })
analytics.trackDevicePairingQrShown()
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'spotlight_used')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'spotlight_used', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
@@ -1038,11 +1030,11 @@ describe('useAnalytics conversation product events', () => {
5,
'update_install_clicked',
{ channel: 'stable', version: '0.11.0' },
{ send_instantly: true, transport: 'sendBeacon' },
{ beforeNavigation: true },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_added')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_removed')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_added', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_removed', {})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown', {})
})
})
+108 -113
View File
@@ -1,15 +1,13 @@
import type { ControlsIslandAction } from '../stores/analytics/button-events'
import type { SpeechOutputStopReason } from '../stores/speech-output-control'
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSharedAnalyticsStore } from '../stores/analytics'
import { captureTrackButtonEvent } from '../stores/analytics/button-events'
import { ensurePosthogInitialized, isPosthogAvailableInBuild } from '../stores/analytics/posthog'
import { captureAnalyticsEvent, ensureAnalyticsInitialized, isAnalyticsAvailableInBuild } from '../stores/analytics/client'
import { getAnalyticsPrivacyPolicyUrl } from '../stores/analytics/privacy-policy'
import { useSettingsAnalytics } from '../stores/settings/analytics'
import { useSettingsGeneral } from '../stores/settings/general'
@@ -133,21 +131,20 @@ export function useAnalytics() {
const privacyPolicyUrl = computed(() => getAnalyticsPrivacyPolicyUrl(locale.value || settingsGeneral.language))
const isAnalyticsEnabled = computed(() => isPosthogAvailableInBuild() && settingsAnalytics.analyticsEnabled)
const isAnalyticsEnabled = computed(() => isAnalyticsAvailableInBuild() && settingsAnalytics.analyticsEnabled)
function canCapture(): boolean {
if (!isAnalyticsEnabled.value)
return false
// Ensure PostHog is initialized before any capture call.
return ensurePosthogInitialized(true)
return ensureAnalyticsInitialized(true)
}
function trackProviderClick(providerId: string, module: string) {
if (!canCapture())
return
posthog.capture('provider_card_clicked', {
captureAnalyticsEvent('provider_card_clicked', {
provider_id: providerId,
module,
})
@@ -168,7 +165,7 @@ export function useAnalytics() {
? Date.now() - analyticsStore.appStartTime
: null
posthog.capture('first_message_sent', {
captureAnalyticsEvent('first_message_sent', {
time_to_first_message_ms: timeToFirstMessageMs,
})
}
@@ -189,7 +186,7 @@ export function useAnalytics() {
function trackPricingViewed(entrySurface: string, planPeriod?: 'monthly' | 'annual' | 'one_time') {
if (!canCapture())
return
posthog.capture('pricing_page_viewed', { entry_surface: entrySurface, ...(planPeriod && { plan_period: planPeriod }) })
captureAnalyticsEvent('pricing_page_viewed', { entry_surface: entrySurface, ...(planPeriod && { plan_period: planPeriod }) })
}
/**
@@ -199,7 +196,7 @@ export function useAnalytics() {
function trackPlanSelected(planId: string, properties: { entry_surface: string, price_minor_unit?: number, currency?: string }) {
if (!canCapture())
return
posthog.capture('plan_selected', { plan_id: planId, ...properties })
captureAnalyticsEvent('plan_selected', { plan_id: planId, ...properties })
}
/**
@@ -209,10 +206,8 @@ export function useAnalytics() {
*
* 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.
* `window.location.href = ...`. `beforeNavigation` lets the installed
* adapter choose a delivery mechanism that survives document unload.
*
* The funnel terminator `payment_completed` is forwarded to PostHog
* server-side by the product-events service (allowlist in
@@ -222,10 +217,10 @@ export function useAnalytics() {
function trackCheckoutStarted(planId: string, properties: { entry_surface: string, checkout_session_id?: string, price_minor_unit?: number, currency?: string }) {
if (!canCapture())
return
posthog.capture(
captureAnalyticsEvent(
'checkout_started',
{ plan_id: planId, ...properties },
{ send_instantly: true, transport: 'sendBeacon' },
{ beforeNavigation: true },
)
}
@@ -236,7 +231,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('paywall_seen', {
captureAnalyticsEvent('paywall_seen', {
entry_surface: properties.entry_surface,
app_surface: getConversationAnalyticsSurface(),
reason: properties.reason,
@@ -254,7 +249,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('oauth_callback_failed', {
captureAnalyticsEvent('oauth_callback_failed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -266,13 +261,13 @@ export function useAnalytics() {
function trackPasswordChanged() {
if (!canCapture())
return
posthog.capture('password_changed', { app_surface: getConversationAnalyticsSurface() })
captureAnalyticsEvent('password_changed', { app_surface: getConversationAnalyticsSurface() })
}
function trackPasswordResetRequested() {
if (!canCapture())
return
posthog.capture('password_reset_requested', { app_surface: getConversationAnalyticsSurface() })
captureAnalyticsEvent('password_reset_requested', { app_surface: getConversationAnalyticsSurface() })
}
function trackOauthProviderLinkStarted(properties: { provider: string }) {
@@ -281,20 +276,20 @@ export function useAnalytics() {
// The only caller (`useLinkedAccounts.link`) navigates to the OAuth
// consent page right after this hook — the batched queue would race
// the unload and drop the event, same as `trackCheckoutStarted`.
posthog.capture(
captureAnalyticsEvent(
'oauth_provider_link_started',
{
...properties,
app_surface: getConversationAnalyticsSurface(),
},
{ send_instantly: true, transport: 'sendBeacon' },
{ beforeNavigation: true },
)
}
function trackOauthProviderUnlinked(properties: { provider: string }) {
if (!canCapture())
return
posthog.capture('oauth_provider_unlinked', {
captureAnalyticsEvent('oauth_provider_unlinked', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -308,13 +303,13 @@ export function useAnalytics() {
function trackAccountDeletionRequested() {
if (!canCapture())
return
posthog.capture('account_deletion_requested', { app_surface: getConversationAnalyticsSurface() })
captureAnalyticsEvent('account_deletion_requested', { app_surface: getConversationAnalyticsSurface() })
}
function trackOnboardingStarted(properties: { entry: ProductAnalyticsEntry }) {
if (!canCapture())
return
posthog.capture('onboarding_started', {
captureAnalyticsEvent('onboarding_started', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -323,7 +318,7 @@ export function useAnalytics() {
function trackOnboardingCompleted(properties: OnboardingProviderProperties) {
if (!canCapture())
return
posthog.capture('onboarding_completed', {
captureAnalyticsEvent('onboarding_completed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -333,14 +328,14 @@ export function useAnalytics() {
function trackCharacterCreated(properties: { character_type: 'built_in' | 'custom', voice_enabled: boolean }) {
if (!canCapture())
return
posthog.capture('character_created', properties)
captureAnalyticsEvent('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 } : {})
captureAnalyticsEvent('voice_mode_activated', characterId ? { character_id: characterId } : {})
}
/**
@@ -351,8 +346,8 @@ export function useAnalytics() {
function trackModelSwitched(fromModel: string, toModel: string, reason: 'manual' | 'auto' = 'manual') {
if (!canCapture())
return
posthog.capture('model_switched', { from_model: fromModel, to_model: toModel, reason })
posthog.capture('model_changed', {
captureAnalyticsEvent('model_switched', { from_model: fromModel, to_model: toModel, reason })
captureAnalyticsEvent('model_changed', {
from_model: fromModel,
to_model: toModel,
reason,
@@ -368,7 +363,7 @@ export function useAnalytics() {
function trackChatSessionStarted(modelId: string, sessionIndex?: number) {
if (!canCapture())
return
posthog.capture('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) })
captureAnalyticsEvent('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) })
}
// ─── LLM round events (client-known fields only) ──────────────────────
@@ -381,27 +376,27 @@ export function useAnalytics() {
function trackMessageSendStarted(properties: ChatRoundCorrelationProperties & { source: 'text' | 'voice', model?: string }) {
if (!canCapture())
return
posthog.capture('message_send_started', properties)
captureAnalyticsEvent('message_send_started', properties)
}
function trackLlmRequestStarted(properties: ChatRoundCorrelationProperties & { model: string, provider: string, has_voice: boolean }) {
if (!canCapture())
return
posthog.capture('llm_request_started', properties)
captureAnalyticsEvent('llm_request_started', properties)
}
/** First token from a streaming LLM response — perceived responsiveness anchor. */
function trackLlmFirstToken(properties: ChatRoundCorrelationProperties & { model: string, ttfb_ms: number }) {
if (!canCapture())
return
posthog.capture('llm_first_token', properties)
captureAnalyticsEvent('llm_first_token', properties)
}
/** Stream finished and the UI has fully rendered the assistant message. */
function trackAssistantResponseRendered(properties: ChatRoundCorrelationProperties & { model: string, latency_ms: number }) {
if (!canCapture())
return
posthog.capture('assistant_response_rendered', properties)
captureAnalyticsEvent('assistant_response_rendered', properties)
}
/** Cost-fact event for one custom-provider generation; content is intentionally excluded. */
@@ -424,7 +419,7 @@ export function useAnalytics() {
? properties.input_tokens + properties.output_tokens
: undefined)
posthog.capture('$ai_generation', {
captureAnalyticsEvent('$ai_generation', {
$ai_trace_id: properties.conversation_id,
$ai_session_id: properties.conversation_id,
$ai_span_id: properties.round_id,
@@ -459,7 +454,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('message_round', properties)
captureAnalyticsEvent('message_round', properties)
}
/** Canonical failure event for every user-to-assistant round, including post-activation turns. */
@@ -472,7 +467,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('message_round_failed', {
captureAnalyticsEvent('message_round_failed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -483,7 +478,7 @@ export function useAnalytics() {
function trackChatActivationStarted(properties: ChatActivationBaseProperties) {
if (!canCapture())
return
posthog.capture('chat_activation_started', {
captureAnalyticsEvent('chat_activation_started', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -492,7 +487,7 @@ export function useAnalytics() {
function trackChatActivationSucceeded(properties: ChatActivationBaseProperties & { time_to_first_message_ms?: number }) {
if (!canCapture())
return
posthog.capture('chat_activation_succeeded', {
captureAnalyticsEvent('chat_activation_succeeded', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -504,7 +499,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('chat_activation_failed', {
captureAnalyticsEvent('chat_activation_failed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -519,7 +514,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('official_provider_selected', {
captureAnalyticsEvent('official_provider_selected', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -536,7 +531,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('message_sent', {
captureAnalyticsEvent('message_sent', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -545,7 +540,7 @@ export function useAnalytics() {
function trackSecondTurnStarted(properties: ChatActivationBaseProperties) {
if (!canCapture())
return
posthog.capture('second_turn_started', {
captureAnalyticsEvent('second_turn_started', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -559,7 +554,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('model_list_loaded', {
captureAnalyticsEvent('model_list_loaded', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -573,7 +568,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('model_list_failed', {
captureAnalyticsEvent('model_list_failed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -582,7 +577,7 @@ export function useAnalytics() {
function trackProviderConfigStarted(properties: ProviderConfigBaseProperties) {
if (!canCapture())
return
posthog.capture('provider_config_started', {
captureAnalyticsEvent('provider_config_started', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -591,7 +586,7 @@ export function useAnalytics() {
function trackProviderConfigSucceeded(properties: ProviderConfigBaseProperties & { duration_ms: number }) {
if (!canCapture())
return
posthog.capture('provider_config_succeeded', {
captureAnalyticsEvent('provider_config_succeeded', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -613,7 +608,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('provider_config_failed', {
captureAnalyticsEvent('provider_config_failed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -626,7 +621,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('provider_config_completed', {
captureAnalyticsEvent('provider_config_completed', {
...properties,
provider_type: properties.provider_mode,
provider_name: properties.provider_id,
@@ -641,7 +636,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('official_provider_enabled', {
captureAnalyticsEvent('official_provider_enabled', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -652,7 +647,7 @@ export function useAnalytics() {
function trackTtsStopClicked(properties: { reason: SpeechOutputStopReason }) {
if (!canCapture())
return
posthog.capture('tts_stop_clicked', {
captureAnalyticsEvent('tts_stop_clicked', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -664,7 +659,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('speech_mute_toggled', {
captureAnalyticsEvent('speech_mute_toggled', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -673,7 +668,7 @@ export function useAnalytics() {
function trackChatSessionSelected(properties: { source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) {
if (!canCapture())
return
posthog.capture('chat_session_selected', {
captureAnalyticsEvent('chat_session_selected', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -682,7 +677,7 @@ export function useAnalytics() {
function trackChatMessageDeleted(properties: { source: 'history', message_role: string }) {
if (!canCapture())
return
posthog.capture('chat_message_deleted', {
captureAnalyticsEvent('chat_message_deleted', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -691,7 +686,7 @@ export function useAnalytics() {
function trackChatMessagesCleared(properties: { source: 'chat_controls', message_count: number }) {
if (!canCapture())
return
posthog.capture('chat_messages_cleared', {
captureAnalyticsEvent('chat_messages_cleared', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -700,7 +695,7 @@ export function useAnalytics() {
function trackChatMessageRetried(properties: { source: 'history' }) {
if (!canCapture())
return
posthog.capture('chat_message_retried', {
captureAnalyticsEvent('chat_message_retried', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -714,7 +709,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('conversation_created', {
captureAnalyticsEvent('conversation_created', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -726,7 +721,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('conversation_renamed', {
captureAnalyticsEvent('conversation_renamed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -738,7 +733,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('conversation_shared', {
captureAnalyticsEvent('conversation_shared', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -751,7 +746,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('conversation_deleted', {
captureAnalyticsEvent('conversation_deleted', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -762,29 +757,29 @@ export function useAnalytics() {
function trackSttStarted(provider: string) {
if (!canCapture())
return
posthog.capture('stt_started', { provider })
captureAnalyticsEvent('stt_started', { provider })
}
function trackSttSucceeded(properties: { provider: string, latency_ms: number, char_count: number, stream: boolean }) {
if (!canCapture())
return
posthog.capture('stt_succeeded', properties)
captureAnalyticsEvent('stt_succeeded', properties)
}
function trackSttFailed(properties: { provider: string, error_code?: string }) {
if (!canCapture())
return
posthog.capture('stt_failed', properties)
captureAnalyticsEvent('stt_failed', properties)
}
function trackVoiceInputStarted(properties: VoiceInputBaseProperties) {
if (!canCapture())
return
posthog.capture('voice_input_started', {
captureAnalyticsEvent('voice_input_started', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
posthog.capture('voice_input_used', {
captureAnalyticsEvent('voice_input_used', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -793,7 +788,7 @@ export function useAnalytics() {
function trackMicrophonePermissionRequested(properties: VoiceInputBaseProperties) {
if (!canCapture())
return
posthog.capture('microphone_permission_requested', {
captureAnalyticsEvent('microphone_permission_requested', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -802,7 +797,7 @@ export function useAnalytics() {
function trackMicrophonePermissionDenied(properties: VoiceInputBaseProperties & { error_code?: 'permission_denied' | string }) {
if (!canCapture())
return
posthog.capture('microphone_permission_denied', {
captureAnalyticsEvent('microphone_permission_denied', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -811,7 +806,7 @@ export function useAnalytics() {
function trackAudioDeviceUnavailable(properties: VoiceInputBaseProperties & { error_code?: 'device_unavailable' | string }) {
if (!canCapture())
return
posthog.capture('audio_device_unavailable', {
captureAnalyticsEvent('audio_device_unavailable', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -820,7 +815,7 @@ export function useAnalytics() {
function trackVoiceInputCancelled(properties: VoiceInputBaseProperties) {
if (!canCapture())
return
posthog.capture('voice_input_cancelled', {
captureAnalyticsEvent('voice_input_cancelled', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -835,7 +830,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('bug_report_submitted', {
captureAnalyticsEvent('bug_report_submitted', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -844,7 +839,7 @@ export function useAnalytics() {
function trackFeedbackSubmitted(properties: FeedbackBaseProperties) {
if (!canCapture())
return
posthog.capture('feedback_submitted', {
captureAnalyticsEvent('feedback_submitted', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -855,13 +850,13 @@ export function useAnalytics() {
function trackPttPressed() {
if (!canCapture())
return
posthog.capture('ptt_pressed')
captureAnalyticsEvent('ptt_pressed', {})
}
function trackPttReleased(holdMs: number) {
if (!canCapture())
return
posthog.capture('ptt_released', { hold_ms: holdMs })
captureAnalyticsEvent('ptt_released', { hold_ms: holdMs })
}
// ─── TTS events (forwarded from speech bus by use-speech-pipeline-analytics) ─
@@ -872,25 +867,25 @@ export function useAnalytics() {
function trackTtsIntentStarted(properties: { intent_id: string, turn_id?: string }) {
if (!canCapture())
return
posthog.capture('tts_intent_started', properties)
captureAnalyticsEvent('tts_intent_started', properties)
}
function trackTtsIntentEnded(properties: { intent_id: string, turn_id?: string, duration_ms: number }) {
if (!canCapture())
return
posthog.capture('tts_intent_ended', properties)
captureAnalyticsEvent('tts_intent_ended', properties)
}
function trackTtsIntentCancelled(properties: { intent_id: string, turn_id?: string, reason?: string }) {
if (!canCapture())
return
posthog.capture('tts_intent_cancelled', properties)
captureAnalyticsEvent('tts_intent_cancelled', properties)
}
function trackTtsProviderSelected(properties: TtsVoiceBaseProperties) {
if (!canCapture())
return
posthog.capture('tts_provider_selected', {
captureAnalyticsEvent('tts_provider_selected', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -903,7 +898,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('voice_selected', {
captureAnalyticsEvent('voice_selected', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -916,7 +911,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('voice_preview_played', {
captureAnalyticsEvent('voice_preview_played', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -928,7 +923,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('voice_pack_bound', {
captureAnalyticsEvent('voice_pack_bound', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -942,7 +937,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('attachment_uploaded', {
captureAnalyticsEvent('attachment_uploaded', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -951,7 +946,7 @@ export function useAnalytics() {
function trackOfficialTtsExposed(properties: OfficialTtsBaseProperties) {
if (!canCapture())
return
posthog.capture('official_tts_exposed', {
captureAnalyticsEvent('official_tts_exposed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -964,7 +959,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('preset_used', {
captureAnalyticsEvent('preset_used', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -978,7 +973,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('official_tts_preview_started', {
captureAnalyticsEvent('official_tts_preview_started', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -993,7 +988,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('official_tts_preview_succeeded', {
captureAnalyticsEvent('official_tts_preview_succeeded', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1008,7 +1003,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('provider_switched', {
captureAnalyticsEvent('provider_switched', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1022,7 +1017,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('settings_changed', {
captureAnalyticsEvent('settings_changed', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1035,7 +1030,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('support_contacted', {
captureAnalyticsEvent('support_contacted', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1047,7 +1042,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('official_tts_auto_enabled', {
captureAnalyticsEvent('official_tts_auto_enabled', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1058,7 +1053,7 @@ export function useAnalytics() {
function trackAutonomousGenerateText(properties: { model: string, reason?: string }) {
if (!canCapture())
return
posthog.capture('autonomous_generate_text', properties)
captureAnalyticsEvent('autonomous_generate_text', properties)
}
// ─── AIRI card (ccv3 character card) events ──────────────────────────
@@ -1071,7 +1066,7 @@ export function useAnalytics() {
function trackCardEdited(properties: { card_id: string }) {
if (!canCapture())
return
posthog.capture('card_edited', {
captureAnalyticsEvent('card_edited', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1081,7 +1076,7 @@ export function useAnalytics() {
function trackSceneBackgroundSet(properties: { source: 'scene_settings' | 'card_gallery', cleared: boolean }) {
if (!canCapture())
return
posthog.capture('scene_background_set', {
captureAnalyticsEvent('scene_background_set', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1090,7 +1085,7 @@ export function useAnalytics() {
function trackCharacterUpdated(properties: { character_id: string }) {
if (!canCapture())
return
posthog.capture('character_updated', properties)
captureAnalyticsEvent('character_updated', properties)
}
// ─── App lifecycle ───────────────────────────────────────────────────
@@ -1098,7 +1093,7 @@ export function useAnalytics() {
function trackAppLoaded(properties: { platform: 'web' | 'desktop' | 'mobile', version: string, cold_start_ms?: number }) {
if (!canCapture())
return
posthog.capture('app_loaded', properties)
captureAnalyticsEvent('app_loaded', properties)
}
// ─── Feature usage / retention ───────────────────────────────────────
@@ -1106,31 +1101,31 @@ export function useAnalytics() {
function trackCharacterDeleted(properties: { character_id: string }) {
if (!canCapture())
return
posthog.capture('character_deleted', properties)
captureAnalyticsEvent('character_deleted', properties)
}
function trackCharacterSwitched(properties: { from_character_id?: string, to_character_id: string }) {
if (!canCapture())
return
posthog.capture('character_switched', properties)
captureAnalyticsEvent('character_switched', properties)
}
function trackChatSessionDeleted(properties: { session_id: string, message_count: number }) {
if (!canCapture())
return
posthog.capture('chat_session_deleted', properties)
captureAnalyticsEvent('chat_session_deleted', properties)
}
function trackOnboardingStepCompleted(step: string) {
if (!canCapture())
return
posthog.capture('onboarding_step_completed', { step })
captureAnalyticsEvent('onboarding_step_completed', { step })
}
function trackOnboardingSkipped(at_step: string) {
if (!canCapture())
return
posthog.capture('onboarding_skipped', { at_step })
captureAnalyticsEvent('onboarding_skipped', { at_step })
}
// ─── Monetization (client side) ──────────────────────────────────────
@@ -1138,13 +1133,13 @@ export function useAnalytics() {
function trackFluxLowWarningShown(properties: { balance: number, threshold: number }) {
if (!canCapture())
return
posthog.capture('flux_low_warning_shown', properties)
captureAnalyticsEvent('flux_low_warning_shown', properties)
}
function trackFluxTopupClicked(properties: { balance: number, entry_surface: string }) {
if (!canCapture())
return
posthog.capture('flux_topup_clicked', properties)
captureAnalyticsEvent('flux_topup_clicked', properties)
}
function trackQuotaLimitReached(properties: {
@@ -1155,7 +1150,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('quota_limit_reached', properties)
captureAnalyticsEvent('quota_limit_reached', properties)
}
function trackUpgradeClicked(properties: {
@@ -1165,7 +1160,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('upgrade_clicked', properties)
captureAnalyticsEvent('upgrade_clicked', properties)
}
function trackFeatureUsed(properties: {
@@ -1176,7 +1171,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('feature_used', {
captureAnalyticsEvent('feature_used', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1195,7 +1190,7 @@ export function useAnalytics() {
}) {
if (!canCapture())
return
posthog.capture('data_action', {
captureAnalyticsEvent('data_action', {
...properties,
app_surface: getConversationAnalyticsSurface(),
})
@@ -1214,13 +1209,13 @@ export function useAnalytics() {
function trackSpotlightUsed() {
if (!canCapture())
return
posthog.capture('spotlight_used')
captureAnalyticsEvent('spotlight_used', {})
}
function trackWidgetOpened(properties: { widget_id: string }) {
if (!canCapture())
return
posthog.capture('widget_opened', properties)
captureAnalyticsEvent('widget_opened', properties)
}
function trackUpdateCheckClicked(properties: { channel: string }) {
@@ -1230,7 +1225,7 @@ export function useAnalytics() {
function trackUpdateDownloaded(properties: { channel: string, version?: string }) {
if (!canCapture())
return
posthog.capture('update_downloaded', properties)
captureAnalyticsEvent('update_downloaded', properties)
}
/** User confirmed restart-and-install; the app quits right after. */
@@ -1249,14 +1244,14 @@ export function useAnalytics() {
function trackMcpConnectionTestRun(properties: { success: boolean }) {
if (!canCapture())
return
posthog.capture('mcp_connection_test_run', properties)
captureAnalyticsEvent('mcp_connection_test_run', properties)
}
/** Pairing QR revealed — the funnel start for `device_channel_connected`. */
function trackDevicePairingQrShown() {
if (!canCapture())
return
posthog.capture('device_pairing_qr_shown')
captureAnalyticsEvent('device_pairing_qr_shown', {})
}
// ─── Voice clone (custom TTS voice) ──────────────────────────────────
@@ -1264,7 +1259,7 @@ export function useAnalytics() {
function trackVoiceCloneCreated(properties: { provider: string }) {
if (!canCapture())
return
posthog.capture('voice_clone_created', properties)
captureAnalyticsEvent('voice_clone_created', properties)
}
// ─── Device pairing / channel (Electron / Tamagotchi) ─────────────────
@@ -1272,7 +1267,7 @@ export function useAnalytics() {
function trackDeviceChannelConnected(properties: { channel: string }) {
if (!canCapture())
return
posthog.capture('device_channel_connected', properties)
captureAnalyticsEvent('device_channel_connected', properties)
}
return {
@@ -7,7 +7,7 @@ const authMocks = vi.hoisted(() => ({
}))
const posthogMocks = vi.hoisted(() => ({
getPosthogIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({
getAnalyticsIdentitySnapshot: vi.fn<() => { distinctId: string, sessionId: string } | null>(() => ({
distinctId: 'distinct-1',
sessionId: 'session-1',
})),
@@ -17,15 +17,15 @@ vi.mock('./auth', () => ({
getAuthToken: authMocks.getAuthToken,
}))
vi.mock('../stores/analytics/posthog', () => ({
getPosthogIdentitySnapshot: posthogMocks.getPosthogIdentitySnapshot,
vi.mock('../stores/analytics/client', () => ({
getAnalyticsIdentitySnapshot: posthogMocks.getAnalyticsIdentitySnapshot,
}))
describe('authedFetch', () => {
beforeEach(() => {
vi.restoreAllMocks()
authMocks.getAuthToken.mockReturnValue('access-token')
posthogMocks.getPosthogIdentitySnapshot.mockReturnValue({
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue({
distinctId: 'distinct-1',
sessionId: 'session-1',
})
@@ -47,7 +47,7 @@ describe('authedFetch', () => {
})
it('omits PostHog identity headers when analytics has no active identity', async () => {
posthogMocks.getPosthogIdentitySnapshot.mockReturnValue(null)
posthogMocks.getAnalyticsIdentitySnapshot.mockReturnValue(null)
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async () => new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
+2 -2
View File
@@ -1,4 +1,4 @@
import { getPosthogIdentitySnapshot } from '../stores/analytics/posthog'
import { getAnalyticsIdentitySnapshot } from '../stores/analytics/client'
import { useAuthStore } from '../stores/auth'
import { getAuthToken } from './auth'
import { SERVER_URL } from './server'
@@ -27,7 +27,7 @@ export async function authedFetch(
const headers = new Headers(init?.headers)
if (token)
headers.set('Authorization', `Bearer ${token}`)
const posthogIdentity = shouldAttachPosthogIdentity(input) ? getPosthogIdentitySnapshot() : null
const posthogIdentity = shouldAttachPosthogIdentity(input) ? getAnalyticsIdentitySnapshot() : null
if (posthogIdentity) {
headers.set('x-posthog-distinct-id', posthogIdentity.distinctId)
if (posthogIdentity.sessionId)
@@ -1,9 +1,7 @@
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { useSettingsAnalytics } from '../settings/analytics'
import { ensurePosthogInitialized, isPosthogAvailableInBuild } from './posthog'
import { captureAnalyticsEvent, ensureAnalyticsInitialized, isAnalyticsAvailableInBuild } from './client'
/** Stable, low-cardinality actions emitted by the Electron controls island. */
export type ControlsIslandAction
@@ -52,9 +50,9 @@ export type TrackButtonEvent
function canCapture(): boolean {
const settingsAnalytics = useSettingsAnalytics()
return isPosthogAvailableInBuild()
return isAnalyticsAvailableInBuild()
&& settingsAnalytics.analyticsEnabled
&& ensurePosthogInitialized(true)
&& ensureAnalyticsInitialized(true)
}
function appSurface(): 'web' | 'mobile' | 'electron' {
@@ -84,27 +82,27 @@ export function captureTrackButtonEvent(event: TrackButtonEvent) {
app_surface: appSurface(),
}
if (event.action === 'refresh_window' || event.action === 'close_app') {
posthog.capture(event.name, properties, { send_instantly: true, transport: 'sendBeacon' })
captureAnalyticsEvent(event.name, properties, { beforeNavigation: true })
return
}
posthog.capture(event.name, properties)
captureAnalyticsEvent(event.name, properties)
return
}
case 'update_check_clicked':
posthog.capture(event.name, { channel: event.channel })
captureAnalyticsEvent(event.name, { channel: event.channel })
return
case 'update_install_clicked':
posthog.capture(
captureAnalyticsEvent(
event.name,
{ channel: event.channel, ...(event.version && { version: event.version }) },
{ send_instantly: true, transport: 'sendBeacon' },
{ beforeNavigation: true },
)
return
case 'mcp_server_added':
posthog.capture(event.name)
captureAnalyticsEvent(event.name, {})
return
case 'mcp_server_removed':
posthog.capture(event.name)
captureAnalyticsEvent(event.name, {})
}
}
@@ -0,0 +1,56 @@
import type { AnalyticsAdapter } from './client'
import { describe, expect, it, vi } from 'vitest'
import { AnalyticsClient } from './client'
function createAdapter(): AnalyticsAdapter {
return {
capture: vi.fn(() => true),
getIdentitySnapshot: vi.fn(() => ({ distinctId: 'distinct-1', sessionId: 'session-1' })),
identify: vi.fn(),
registerBuildInfo: vi.fn(),
resetIdentity: vi.fn(),
setCaptureEnabled: vi.fn(enabled => enabled),
}
}
describe('analytics client', () => {
it('queues events while a provider adapter is loading and flushes them in order', async () => {
const adapter = createAdapter()
let install: ((adapter: AnalyticsAdapter) => void) | undefined
const client = new AnalyticsClient(() => new Promise<AnalyticsAdapter>((resolve) => {
install = resolve
}))
expect(client.ensureInitialized(true)).toBe(true)
expect(client.capture('app_loaded', { platform: 'web' })).toBe(true)
client.identify('user-1')
await Promise.resolve()
install?.(adapter)
await vi.waitFor(() => {
expect(adapter.capture).toHaveBeenCalledWith('app_loaded', { platform: 'web' }, undefined)
})
expect(adapter.identify).toHaveBeenCalledWith('user-1')
})
// ROOT CAUSE:
//
// A content blocker rejects the provider adapter's dynamic module request.
// If that rejection escapes into application startup, Vue never mounts.
//
// The client converts provider loading failure into a permanent no-op state.
it('fails open when a content blocker rejects the provider adapter', async () => {
const client = new AnalyticsClient(async () => {
throw new TypeError('Failed to fetch dynamically imported module')
})
expect(client.ensureInitialized(true)).toBe(true)
expect(client.capture('app_loaded', { platform: 'web' })).toBe(true)
await vi.waitFor(() => {
expect(client.capture('first_message_sent', {})).toBe(false)
})
expect(client.getIdentitySnapshot()).toBeNull()
})
})
@@ -0,0 +1,235 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import { isEnvTruthy } from '@proj-airi/stage-shared'
export interface AnalyticsIdentitySnapshot {
/** Current provider distinct id for the browser/device/user person. */
distinctId: string
/** Current provider session id, when one has been established. */
sessionId?: string
}
/** Provider-neutral delivery hints for one product event. */
export interface AnalyticsCaptureOptions {
/**
* Indicates that document navigation immediately follows capture, allowing
* the adapter to select an unload-safe delivery mechanism.
* @default false
*/
beforeNavigation?: boolean
}
/** Provider contract installed behind the shared analytics façade. */
export interface AnalyticsAdapter {
capture: (name: string, properties: object, options?: AnalyticsCaptureOptions) => boolean
getIdentitySnapshot: () => AnalyticsIdentitySnapshot | null
identify: (userId: string) => void
registerBuildInfo: (buildInfo: AboutBuildInfo) => void
resetIdentity: () => void
setCaptureEnabled: (enabled: boolean) => boolean
}
/** Initialization state supplied to an analytics adapter loader. */
export interface AnalyticsAdapterOptions {
/** Whether capture is enabled when the adapter initializes. */
enabled: boolean
}
/** Loads one provider adapter. Rejections permanently degrade the client to no-op. */
export type AnalyticsAdapterLoader = (options: AnalyticsAdapterOptions) => Promise<AnalyticsAdapter>
type PendingOperation
= | { kind: 'capture', name: string, properties: object, options?: AnalyticsCaptureOptions }
| { kind: 'identify', userId: string }
| { kind: 'register-build-info', buildInfo: AboutBuildInfo }
| { kind: 'reset-identity' }
| { kind: 'set-capture-enabled', enabled: boolean }
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
/**
* Owns an optional provider's lifecycle and keeps provider loading out of the
* static application graph. Calls made while loading use a bounded queue.
*/
export class AnalyticsClient {
private adapter: AnalyticsAdapter | undefined
private captureEnabled = false
private loadPromise: Promise<boolean> | undefined
private loadState: LoadState = 'idle'
private readonly pendingOperations: PendingOperation[] = []
constructor(private readonly loader: AnalyticsAdapterLoader) {}
ensureInitialized(enabled: boolean): boolean {
this.captureEnabled = enabled
if (!enabled || this.loadState === 'unavailable')
return false
if (this.loadState === 'idle')
void this.load(enabled)
return true
}
syncCapture(enabled: boolean): boolean {
this.captureEnabled = enabled
if (enabled)
return this.ensureInitialized(true)
if (this.adapter)
this.adapter.setCaptureEnabled(false)
else if (this.loadState === 'loading')
this.enqueue({ kind: 'set-capture-enabled', enabled: false })
return false
}
registerBuildInfo(buildInfo: AboutBuildInfo): void {
if (this.adapter)
this.adapter.registerBuildInfo(buildInfo)
else if (this.loadState === 'loading')
this.enqueue({ kind: 'register-build-info', buildInfo })
}
identify(userId: string): void {
if (this.adapter)
this.adapter.identify(userId)
else if (this.loadState === 'loading')
this.enqueue({ kind: 'identify', userId })
}
resetIdentity(): void {
if (this.adapter)
this.adapter.resetIdentity()
else if (this.loadState === 'loading')
this.enqueue({ kind: 'reset-identity' })
}
getIdentitySnapshot(): AnalyticsIdentitySnapshot | null {
return this.adapter?.getIdentitySnapshot() ?? null
}
capture(name: string, properties: object, options?: AnalyticsCaptureOptions): boolean {
if (!this.captureEnabled)
return false
if (this.adapter)
return this.adapter.capture(name, properties, options)
if (this.loadState === 'loading') {
this.enqueue({ kind: 'capture', name, properties, options })
return true
}
return false
}
private load(enabled: boolean): Promise<boolean> {
if (this.loadPromise)
return this.loadPromise
this.loadState = 'loading'
this.loadPromise = Promise.resolve()
.then(() => this.loader({ enabled }))
.then((adapter) => {
this.adapter = adapter
this.loadState = 'ready'
this.flush()
return true
})
.catch(() => {
// A denied dynamic module request is expected under content blockers.
// Product events are optional, so core application work stays no-op.
this.pendingOperations.length = 0
this.loadState = 'unavailable'
return false
})
return this.loadPromise
}
private enqueue(operation: PendingOperation): void {
// Keep the latest events without letting a slow provider grow memory for
// the lifetime of a long-running desktop session.
if (this.pendingOperations.length === 100)
this.pendingOperations.shift()
this.pendingOperations.push(operation)
}
private flush(): void {
if (!this.adapter)
return
for (const operation of this.pendingOperations) {
switch (operation.kind) {
case 'capture':
this.adapter.capture(operation.name, operation.properties, operation.options)
break
case 'identify':
this.adapter.identify(operation.userId)
break
case 'register-build-info':
this.adapter.registerBuildInfo(operation.buildInfo)
break
case 'reset-identity':
this.adapter.resetIdentity()
break
case 'set-capture-enabled':
this.adapter.setCaptureEnabled(operation.enabled)
}
}
this.pendingOperations.length = 0
}
}
let adapterLoader: AnalyticsAdapterLoader | undefined
const analytics = new AnalyticsClient(async (options) => {
if (!adapterLoader)
throw new Error('No analytics adapter has been configured')
return adapterLoader(options)
})
/** Configures the provider loader before the shared store initializes. */
export function configureAnalyticsAdapter(loader: AnalyticsAdapterLoader): void {
adapterLoader = loader
}
export function isAnalyticsAvailableInBuild(): boolean {
return isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)
}
export function ensureAnalyticsInitialized(enabled: boolean): boolean {
if (!isAnalyticsAvailableInBuild() || !adapterLoader)
return false
return analytics.ensureInitialized(enabled)
}
export function syncAnalyticsCapture(enabled: boolean): boolean {
if (!isAnalyticsAvailableInBuild() || !adapterLoader)
return false
return analytics.syncCapture(enabled)
}
export function registerAnalyticsBuildInfo(buildInfo: AboutBuildInfo): void {
analytics.registerBuildInfo(buildInfo)
}
export function identifyAnalyticsUser(userId: string): void {
analytics.identify(userId)
}
export function resetAnalyticsIdentity(): void {
analytics.resetIdentity()
}
export function getAnalyticsIdentitySnapshot(): AnalyticsIdentitySnapshot | null {
return analytics.getIdentitySnapshot()
}
/**
* Emits an event through the configured provider. Returns `false` when capture
* is disabled or the provider is unavailable, allowing callers to gate dedup.
*/
export function captureAnalyticsEvent(name: string, properties: object, options?: AnalyticsCaptureOptions): boolean {
return analytics.capture(name, properties, options)
}
+27 -28
View File
@@ -10,15 +10,15 @@ import { useAiriCardStore } from '../modules/airi-card'
import { useConsciousnessStore } from '../modules/consciousness'
import { useSettingsAnalytics } from '../settings/analytics'
import {
capturePosthogEvent,
identifyPosthogUser,
isPosthogAvailableInBuild,
registerPosthogBuildInfo,
resetPosthog,
syncPosthogCapture,
} from './posthog'
captureAnalyticsEvent,
identifyAnalyticsUser,
isAnalyticsAvailableInBuild,
registerAnalyticsBuildInfo,
resetAnalyticsIdentity,
syncAnalyticsCapture,
} from './client'
export * from './posthog'
export * from './client'
export * from './privacy-policy'
function analyticsSurface(): 'web' | 'desktop' | 'mobile' {
@@ -45,8 +45,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
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.
// sessions" with provider-side person-level dedup at query time.
const firstModelSelectedTracked = ref(false)
watch(analyticsEnabled, (enabled, previousEnabled) => {
@@ -54,7 +53,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
return
if (previousEnabled && !enabled) {
capturePosthogEvent('settings_changed', {
captureAnalyticsEvent('settings_changed', {
setting_name: 'analytics_enabled',
previous_value: previousEnabled,
new_value: enabled,
@@ -63,10 +62,10 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
})
}
const shouldCapture = syncPosthogCapture(enabled)
const shouldCapture = syncAnalyticsCapture(enabled)
if (shouldCapture) {
if (!previousEnabled && enabled) {
capturePosthogEvent('settings_changed', {
captureAnalyticsEvent('settings_changed', {
setting_name: 'analytics_enabled',
previous_value: previousEnabled,
new_value: enabled,
@@ -83,7 +82,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
markFirstMessageTracked()
}
registerPosthogBuildInfo(buildInfo.value)
registerAnalyticsBuildInfo(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
@@ -92,7 +91,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
// anonymous funnel events.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyPosthogUser(authStore.user.id)
identifyAnalyticsUser(authStore.user.id)
}
})
@@ -102,12 +101,12 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
appStartTime.value = Date.now()
if (isPosthogAvailableInBuild()) {
const shouldCapture = syncPosthogCapture(analyticsEnabled.value)
if (isAnalyticsAvailableInBuild()) {
const shouldCapture = syncAnalyticsCapture(analyticsEnabled.value)
if (shouldCapture) {
registerPosthogBuildInfo(buildInfo.value)
registerAnalyticsBuildInfo(buildInfo.value)
const platform = analyticsSurface()
capturePosthogEvent('app_loaded', {
captureAnalyticsEvent('app_loaded', {
platform,
version: buildInfo.value.version,
})
@@ -121,14 +120,14 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
// `apps/server/docs/ai-context/metrics-ownership.md`.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyPosthogUser(authStore.user.id)
identifyAnalyticsUser(authStore.user.id)
authStore.onAuthenticated(() => {
if (authStore.user?.id)
identifyPosthogUser(authStore.user.id)
identifyAnalyticsUser(authStore.user.id)
})
authStore.onLogout(() => {
resetPosthog()
resetAnalyticsIdentity()
})
// Wire model-selection events. The consciousness store holds the
@@ -160,7 +159,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
// 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 })
const captured = captureAnalyticsEvent('first_model_selected', { model_id: next.model, provider: next.provider })
if (captured)
firstModelSelectedTracked.value = true
}
@@ -173,7 +172,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
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 })
const captured = captureAnalyticsEvent('first_model_selected', { model_id: next.model, provider: next.provider })
if (captured)
firstModelSelectedTracked.value = true
return
@@ -183,7 +182,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
// 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.provider && prev.provider !== next.provider) {
capturePosthogEvent('provider_switched', {
captureAnalyticsEvent('provider_switched', {
from_provider: prev.provider,
to_provider: next.provider,
from_provider_type: providerMode(prev.provider),
@@ -194,12 +193,12 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
}
if (prev.model) {
capturePosthogEvent('model_switched', {
captureAnalyticsEvent('model_switched', {
from_model: prev.model,
to_model: next.model,
reason: 'manual',
})
capturePosthogEvent('model_changed', {
captureAnalyticsEvent('model_changed', {
from_model: prev.model,
to_model: next.model,
provider: next.provider,
@@ -222,7 +221,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
// not a switch — skip emit; the first real A→B will fire.
if (!next || !prev || prev === next)
return
capturePosthogEvent('character_switched', {
captureAnalyticsEvent('character_switched', {
from_character_id: prev,
to_character_id: next,
})
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { ensurePosthogInitialized, getPosthogIdentitySnapshot } from './posthog'
import { createPosthogAdapter } from './posthog'
const posthogMocks = vi.hoisted(() => ({
capture: vi.fn(),
get_distinct_id: vi.fn(() => 'distinct-1'),
get_session_id: vi.fn(() => 'session-1'),
has_opted_out_capturing: vi.fn(() => false),
@@ -10,9 +11,7 @@ const posthogMocks = vi.hoisted(() => ({
register: vi.fn(),
}))
vi.mock('posthog-js', () => ({
default: posthogMocks,
}))
vi.mock('posthog-js', () => ({ default: posthogMocks }))
vi.mock('@proj-airi/stage-shared', () => ({
isStageCapacitor: () => false,
@@ -21,27 +20,38 @@ vi.mock('@proj-airi/stage-shared', () => ({
vi.mock('../../../../../posthog.config', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_ENABLED: true,
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
describe('stage PostHog initialization', () => {
describe('posthog analytics adapter', () => {
// ROOT CAUSE:
//
// `surface` was registered as the runtime platform but individual events
// also used `surface` for entry points such as `settings_flux`. Event
// properties overwrite super properties, so platform breakdowns drifted.
it('registers the runtime under the dedicated app_surface property', () => {
expect(ensurePosthogInitialized(true)).toBe(true)
createPosthogAdapter({ enabled: true })
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'web' })
})
it('exposes the current PostHog identity for server-side conversion linking', () => {
expect(ensurePosthogInitialized(true)).toBe(true)
it('exposes the current provider identity for server-side conversion linking', () => {
const adapter = createPosthogAdapter({ enabled: true })
expect(getPosthogIdentitySnapshot()).toEqual({
expect(adapter.getIdentitySnapshot()).toEqual({
distinctId: 'distinct-1',
sessionId: 'session-1',
})
})
it('maps the provider-neutral navigation hint to unload-safe delivery', () => {
const adapter = createPosthogAdapter({ enabled: true })
adapter.capture('checkout_started', { plan_id: 'monthly' }, { beforeNavigation: true })
expect(posthogMocks.capture).toHaveBeenCalledWith(
'checkout_started',
{ plan_id: 'monthly' },
{ send_instantly: true, transport: 'sendBeacon' },
)
})
})
+53 -136
View File
@@ -1,4 +1,5 @@
import type { AboutBuildInfo } from '../../components/scenarios/about/types'
import type { AnalyticsAdapter, AnalyticsAdapterOptions } from './client'
import posthog from 'posthog-js'
@@ -6,158 +7,74 @@ import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY,
} from '../../../../../posthog.config'
let posthogInitialized = false
export interface PosthogIdentitySnapshot {
/** Current PostHog distinct id for the browser/device/user person. */
distinctId: string
/** Current PostHog session id, when the SDK has established one. */
sessionId?: string
}
// All AIRI surfaces (web, desktop, mobile) capture into a single PostHog
// project. The platform is carried on every event via the `app_surface` super
// property (registered at init), so cross-platform funnels live in one
// project instead of being split across per-platform projects.
function currentSurface(): 'web' | 'mobile' | 'electron' {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
export function isPosthogAvailableInBuild(): boolean {
return POSTHOG_ENABLED
}
export function ensurePosthogInitialized(enabled: boolean): boolean {
if (!POSTHOG_ENABLED)
return false
if (posthogInitialized)
return true
/** Creates and initializes the default PostHog analytics adapter. */
export function createPosthogAdapter(options: AnalyticsAdapterOptions): AnalyticsAdapter {
posthog.init(POSTHOG_PROJECT_KEY, {
...DEFAULT_POSTHOG_CONFIG,
opt_out_capturing_by_default: !enabled,
opt_out_capturing_by_default: !options.enabled,
})
// Tag every event (including autocapture / pageview) with the platform so
// the single project can still be broken down by web / desktop / mobile.
posthog.register({ app_surface: currentSurface() })
posthogInitialized = true
return true
}
export function syncPosthogCapture(enabled: boolean): boolean {
if (!POSTHOG_ENABLED)
return false
if (enabled) {
ensurePosthogInitialized(true)
if (posthog.has_opted_out_capturing())
posthog.opt_in_capturing()
return true
}
if (posthogInitialized && !posthog.has_opted_out_capturing())
posthog.opt_out_capturing()
return false
}
export function registerPosthogBuildInfo(buildInfo: AboutBuildInfo): void {
if (!posthogInitialized)
return
posthog.register({
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
app_commit: buildInfo.commit,
app_branch: buildInfo.branch,
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`) the same value the
* server-side product-events forwarder passes as `distinctId` (see
* `apps/server/src/services/domain/product-events.ts`).
*/
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()
}
/**
* Returns the current PostHog identity that server-side conversion events can
* use to merge Stripe webhook facts back into the same browser funnel.
*/
export function getPosthogIdentitySnapshot(): PosthogIdentitySnapshot | null {
if (!posthogInitialized || posthog.has_opted_out_capturing())
return null
const distinctId = posthog.get_distinct_id()
if (!distinctId)
return null
const sessionId = posthog.get_session_id()
return {
distinctId,
...(sessionId && { sessionId }),
capture(name, properties, captureOptions) {
if (posthog.has_opted_out_capturing())
return false
posthog.capture(
name,
{ ...properties },
captureOptions?.beforeNavigation
? { send_instantly: true, transport: 'sendBeacon' }
: undefined,
)
return true
},
getIdentitySnapshot() {
if (posthog.has_opted_out_capturing())
return null
const distinctId = posthog.get_distinct_id()
if (!distinctId)
return null
const sessionId = posthog.get_session_id()
return { distinctId, ...(sessionId && { sessionId }) }
},
identify(userId) {
if (!posthog.has_opted_out_capturing())
posthog.identify(userId)
},
registerBuildInfo(buildInfo: AboutBuildInfo) {
posthog.register({
app_version: (buildInfo.version && buildInfo.version !== '0.0.0') ? buildInfo.version : 'dev',
app_commit: buildInfo.commit,
app_branch: buildInfo.branch,
app_build_time: buildInfo.builtOn,
})
},
resetIdentity() {
posthog.reset()
},
setCaptureEnabled(enabled) {
if (enabled) {
if (posthog.has_opted_out_capturing())
posthog.opt_in_capturing()
return true
}
if (!posthog.has_opted_out_capturing())
posthog.opt_out_capturing()
return false
},
}
}
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
}
@@ -23,7 +23,7 @@ import {
reconcileLocalAndRemote,
} from '../../libs/chat-sync'
import { SERVER_URL } from '../../libs/server'
import { capturePosthogEvent } from '../analytics/posthog'
import { captureAnalyticsEvent } from '../analytics/client'
import { useAuthStore } from '../auth'
import { useAiriCardStore } from '../modules/airi-card'
import { mergeLoadedSessionMessages } from './session-message-merge'
@@ -441,7 +441,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
if (options?.setActive !== false)
activeSessionId.value = sessionId
capturePosthogEvent('conversation_created', {
captureAnalyticsEvent('conversation_created', {
conversation_id: sessionId,
source: options?.messages?.length ? 'fork' : 'new_session',
character_id: characterId,
@@ -485,11 +485,11 @@ export const useChatSessionStore = defineStore('chat-session', () => {
// Snapshot count before the in-memory wipe below zeroes it out.
const messageCount = (sessionMessages.value[sessionId] ?? []).length
capturePosthogEvent('chat_session_deleted', {
captureAnalyticsEvent('chat_session_deleted', {
session_id: sessionId,
message_count: messageCount,
})
capturePosthogEvent('conversation_deleted', {
captureAnalyticsEvent('conversation_deleted', {
conversation_id: sessionId,
message_count: messageCount,
cloud_synced: !!meta.cloudChatId,
@@ -1,73 +0,0 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const currentFilePath = fileURLToPath(import.meta.url)
const currentDirectory = dirname(currentFilePath)
const packageJsonPath = resolve(currentDirectory, '../../package.json')
function readExports() {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
exports: Record<string, string>
}
return packageJson.exports
}
describe('stage-ui exports contract', () => {
it('keeps the exported subpath key set stable', () => {
const exportsMap = readExports()
expect(Object.keys(exportsMap).sort()).toEqual([
'.',
'./components',
'./components/*',
'./components/scenarios/chat',
'./components/scenarios/settings/model-settings',
'./components/scenes',
'./composables',
'./composables/*',
'./constants',
'./constants/*',
'./directives/*',
'./libs',
'./libs/*',
'./libs/inference',
'./libs/inference/adapters/*',
'./services/*',
'./stores',
'./stores/*',
'./stores/analytics',
'./stores/analytics/posthog',
'./stores/analytics/privacy-policy',
'./stores/character',
'./stores/character/orchestrator/spark-notify-agent',
'./stores/mcp-tool-bridge',
'./stores/modules/vision',
'./stores/providers/aliyun',
'./stores/settings',
'./stores/settings/analytics',
'./tools/mcp',
'./types',
'./types/*',
'./utils',
'./utils/tts',
'./workers',
'./workers/*',
'./workers/vad',
])
})
it('keeps critical store and type mappings unchanged', () => {
const exportsMap = readExports()
expect(exportsMap['./stores']).toBe('./src/stores/index.ts')
expect(exportsMap['./stores/*']).toBe('./src/stores/*.ts')
expect(exportsMap['./directives/*']).toBe('./src/directives/*.ts')
expect(exportsMap['./services/*']).toBe('./src/services/*.ts')
expect(exportsMap['./tools/mcp']).toBe('./src/tools/mcp.ts')
expect(exportsMap['./types']).toBe('./src/types/index.ts')
expect(exportsMap['./types/*']).toBe('./src/types/*.ts')
})
})
@@ -11,7 +11,7 @@ import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { capturePosthogEvent } from '../analytics/posthog'
import { captureAnalyticsEvent } from '../analytics/client'
import { useSettingsStageModel } from '../settings/stage-model'
import { useArtistryStore } from './artistry'
import { useConsciousnessStore } from './consciousness'
@@ -76,7 +76,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => {
const newCardId = nanoid()
cards.value.set(newCardId, newAiriCard(card))
capturePosthogEvent('card_created', { card_id: newCardId, source })
captureAnalyticsEvent('card_created', { card_id: newCardId, source })
return newCardId
}
@@ -94,7 +94,7 @@ export const useAiriCardStore = defineStore('airi-card', () => {
if (activeCardId.value === id)
activeCardId.value = 'default'
capturePosthogEvent('character_deleted', { character_id: id })
captureAnalyticsEvent('character_deleted', { character_id: id })
return true
}
+5 -5
View File
@@ -55,7 +55,7 @@ import { getKokoroAdapter } from '../libs/inference/adapters/kokoro'
import { getProviderValidationIntervalMs, listProviders as listDefinedProviders, ProviderValidationCheck } from '../libs/providers'
import { resolveProviderSourceMetadata } from '../libs/providers/source-metadata'
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
import { capturePosthogEvent, ensurePosthogInitialized, isPosthogAvailableInBuild } from './analytics/posthog'
import { captureAnalyticsEvent, ensureAnalyticsInitialized, isAnalyticsAvailableInBuild } from './analytics/client'
import { useAuthStore } from './auth'
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
import { convertProviderDefinitionsToMetadata } from './providers/converters'
@@ -108,14 +108,14 @@ function analyticsSurface(): 'web' | 'mobile' | 'electron' {
* Checks analytics settings and initializes PostHog without loading build metadata.
*/
function canCaptureProviderAnalytics(): boolean {
if (!isPosthogAvailableInBuild())
if (!isAnalyticsAvailableInBuild())
return false
const settingsAnalytics = useSettingsAnalytics()
if (!settingsAnalytics.analyticsEnabled)
return false
return ensurePosthogInitialized(true)
return ensureAnalyticsInitialized(true)
}
/**
@@ -130,7 +130,7 @@ function trackModelListLoaded(properties: {
if (!canCaptureProviderAnalytics())
return
capturePosthogEvent('model_list_loaded', {
captureAnalyticsEvent('model_list_loaded', {
...properties,
app_surface: analyticsSurface(),
})
@@ -148,7 +148,7 @@ function trackModelListFailed(properties: {
if (!canCaptureProviderAnalytics())
return
capturePosthogEvent('model_list_failed', {
captureAnalyticsEvent('model_list_failed', {
...properties,
app_surface: analyticsSurface(),
})