fix(stage-ui,stage-pocket,stage-web,stage-tamagotchi,ui-server-auth): analytics import not deferred or lazied
This commit is contained in:
@@ -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 转发注册、支付、订阅等白名单业务事实到 PostHog;LLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。
|
||||
- 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
|
||||
- 平台统一写入 `app_surface`;`entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。
|
||||
|
||||
@@ -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 }]
|
||||
: []
|
||||
),
|
||||
|
||||
@@ -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,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 }]
|
||||
: []
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user