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

This commit is contained in:
Neko Ayaka
2026-07-30 14:33:21 +08:00
parent fe470ff81c
commit 1927e54c9c
29 changed files with 793 additions and 506 deletions
@@ -112,7 +112,7 @@
### PostHog(前端 / 外部数据源,产品侧)
已接入:
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 动态 adapter 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。
- Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHogLLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。
- 前端 identity`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
- 平台统一写入 `app_surface``entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
@@ -108,7 +108,7 @@ function handleSetupSkipped() {
const extraSteps = computed(() => [
...(
isPosthogAvailableInBuild()
isAnalyticsAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
),
+6
View File
@@ -7,6 +7,7 @@ import NProgress from 'nprogress'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -27,6 +28,11 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
return createPosthogAdapter(options)
})
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
@@ -6,6 +6,7 @@ import Tres from '@tresjs/core'
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -37,6 +38,11 @@ import '@fontsource/kiwi-maru/index.css'
import '@fontsource/m-plus-rounded-1c/index.css'
import '@fontsource-variable/nunito/index.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
return createPosthogAdapter(options)
})
const pinia = createPinia()
const router = createRouter({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { OnboardingScreen, OnboardingStepAnalyticsNotice } from '@proj-airi/stage-ui/components'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useTheme } from '@proj-airi/ui'
@@ -30,7 +30,7 @@ watch(needsLogin, async (val) => {
const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')
const extraSteps = computed(() => {
return isPosthogAvailableInBuild()
return isAnalyticsAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
})
+2 -2
View File
@@ -2,7 +2,7 @@
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { isAnalyticsAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
@@ -65,7 +65,7 @@ const colors = computed(() => {
})
const onboardingExtraSteps = computed(() => {
return isPosthogAvailableInBuild()
return isAnalyticsAvailableInBuild()
? [{ id: 'analytics-notice', component: OnboardingStepAnalyticsNotice }]
: []
})
+6
View File
@@ -8,6 +8,7 @@ import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
import { PiniaColada } from '@pinia/colada'
import { isEnvTruthy } from '@proj-airi/stage-shared'
import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button'
import { configureAnalyticsAdapter } from '@proj-airi/stage-ui/stores/analytics/client'
import { MotionPlugin } from '@vueuse/motion'
import { createPinia } from 'pinia'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -27,6 +28,11 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
configureAnalyticsAdapter(async (options) => {
const { createPosthogAdapter } = await import('@proj-airi/stage-ui/stores/analytics/posthog')
return createPosthogAdapter(options)
})
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution
+17
View File
@@ -91,6 +91,23 @@ export default defineConfig({
},
},
build: {
manifest: true,
rolldownOptions: {
output: {
chunkFileNames: (chunkInfo) => {
const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => {
const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase()
return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog')
})
// Only analytics/provider chunks receive the manual neutral mapping;
// all unrelated chunks retain Vite's readable default naming.
return containsAnalyticsModule
? 'assets/auxiliary-[hash].js'
: 'assets/[name]-[hash].js'
},
},
},
sourcemap: true,
},
worker: {
+7 -2
View File
@@ -14,7 +14,7 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { initAuthAnalytics } from './modules/analytics'
import { loadAnalyticsAdapter } from './modules/analytics'
import { AUTH_UI_ROUTER_BASE_PATH } from './modules/auth-ui-base'
import { i18n } from './modules/i18n'
@@ -24,7 +24,12 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
if (isEnvTruthy(import.meta.env.VITE_ENABLE_POSTHOG)) {
void loadAnalyticsAdapter(async () => {
const { createPosthogAdapter } = await import('./modules/analytics-adapters/posthog')
return createPosthogAdapter()
})
}
const pinia = createPinia()
@@ -0,0 +1,28 @@
import type { AnalyticsAdapter } from '../analytics'
import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_PROJECT_KEY,
} from '../../../../../posthog.config'
/** Creates the auth analytics adapter and initializes its provider SDK. */
export function createPosthogAdapter(): AnalyticsAdapter {
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// The shared project distinguishes auth traffic through this super property.
posthog.register({ app_surface: 'auth' })
return {
capture(event, properties, options) {
posthog.capture(
event,
properties,
options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined,
)
},
identify(userId) {
posthog.identify(userId)
},
}
}
@@ -1,28 +1,22 @@
import type { AnalyticsAdapter } from './analytics'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { initAuthAnalytics, trackSignupFormCompleted } from './analytics'
import {
AnalyticsClient,
loadAnalyticsAdapter,
trackSignupFormCompleted,
} from './analytics'
const posthogMocks = vi.hoisted(() => ({
const adapterMocks = {
capture: vi.fn(),
init: vi.fn(),
register: vi.fn(),
}))
identify: vi.fn(),
} satisfies AnalyticsAdapter
vi.mock('posthog-js', () => ({
default: posthogMocks,
}))
vi.mock('../../../../posthog.config', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_ENABLED: true,
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
describe('auth product analytics', () => {
describe('auth analytics', () => {
beforeEach(() => {
posthogMocks.capture.mockClear()
posthogMocks.init.mockClear()
posthogMocks.register.mockClear()
adapterMocks.capture.mockClear()
adapterMocks.identify.mockClear()
})
// ROOT CAUSE:
@@ -33,16 +27,49 @@ describe('auth product analytics', () => {
//
// The anonymous UI milestone must use its own name. The identified server
// event remains the only canonical `signup_completed` business fact.
it('keeps anonymous signup UI completion separate from the canonical server signup fact', () => {
expect(initAuthAnalytics()).toBe(true)
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'auth' })
it('keeps anonymous signup UI completion separate from the canonical server signup fact', async () => {
await expect(loadAnalyticsAdapter(async () => adapterMocks)).resolves.toBe(true)
trackSignupFormCompleted({ source: 'email', requires_verification: true })
expect(posthogMocks.capture).toHaveBeenCalledWith(
expect(adapterMocks.capture).toHaveBeenCalledWith(
'signup_form_completed',
{ source: 'email', requires_verification: true },
undefined,
{ beforeNavigation: false },
)
})
it('flushes calls made while the optional adapter is loading', async () => {
const client = new AnalyticsClient()
let install: ((adapter: AnalyticsAdapter) => void) | undefined
const loading = client.load(() => new Promise<AnalyticsAdapter>((resolve) => {
install = resolve
}))
client.capture('login_started', { method: 'github' }, { beforeNavigation: true })
client.identify('user-1')
await Promise.resolve()
install?.(adapterMocks)
await expect(loading).resolves.toBe(true)
expect(adapterMocks.capture).toHaveBeenCalledWith(
'login_started',
{ method: 'github' },
{ beforeNavigation: true },
)
expect(adapterMocks.identify).toHaveBeenCalledWith('user-1')
})
it('becomes a harmless no-op when a content blocker rejects the adapter import', async () => {
const client = new AnalyticsClient()
const loading = client.load(async () => {
throw new TypeError('Failed to fetch dynamically imported module')
})
client.capture('login_started', { method: 'google' })
await expect(loading).resolves.toBe(false)
expect(() => client.capture('login_failed', { method: 'google' })).not.toThrow()
expect(adapterMocks.capture).not.toHaveBeenCalled()
})
})
+105 -47
View File
@@ -1,5 +1,5 @@
/**
* PostHog product analytics for the auth-only SPA (`apps/ui-server-auth`).
* Provider-neutral analytics for the auth-only SPA (`apps/ui-server-auth`).
*
* This surface captures anonymous auth-UI milestones such as form completion,
* sign-in attempts, email verification, and password recovery. Canonical
@@ -9,44 +9,120 @@
*
* Unlike the stage apps there is no in-app analytics consent toggle here
* (the user isn't signed in yet, so there's no settings store to read).
* Capture posture matches the docs site: enabled in analytics-enabled
* builds (`VITE_ENABLE_POSTHOG`), disclosed via the privacy policy linked
* on the sign-in page.
* Capture posture matches the docs site: the optional provider is enabled by
* the application entry in configured builds and disclosed via the privacy
* policy linked on the sign-in page.
*/
import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables'
import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY,
} from '../../../../posthog.config'
/** Login/signup credential kinds shown on the sign-in page. */
export type AuthMethod = 'email' | 'github' | 'google'
let initialized = false
interface CaptureOptions {
/**
* Set when navigation immediately follows capture. Adapters can select a
* transport that survives document unload.
*/
beforeNavigation?: boolean
}
/** Adapter contract installed by an optional analytics provider chunk. */
export interface AnalyticsAdapter {
capture: (event: string, properties: Record<string, unknown>, options?: CaptureOptions) => void
identify: (userId: string) => void
}
type PendingOperation
= | { kind: 'capture', event: string, properties: Record<string, unknown>, options?: CaptureOptions }
| { kind: 'identify', userId: string }
type LoadState = 'idle' | 'loading' | 'ready' | 'unavailable'
/**
* Initialize PostHog for the auth surface. Call once from `main.ts` before
* mount; later calls are no-ops. Returns whether capture is active so
* callers can skip building event payloads in analytics-disabled builds.
* Owns optional-adapter loading and guarantees that product-event calls never
* make core auth UI wait for, or depend on, a provider SDK.
*/
export function initAuthAnalytics(): boolean {
if (!POSTHOG_ENABLED)
return false
export class AnalyticsClient {
private adapter: AnalyticsAdapter | undefined
private loadPromise: Promise<boolean> | undefined
private loadState: LoadState = 'idle'
private readonly pendingOperations: PendingOperation[] = []
if (initialized)
return true
load(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
if (this.loadPromise)
return this.loadPromise
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// Same single-project setup as the stage apps: the `app_surface` super
// property is how auth traffic is told apart in shared dashboards.
posthog.register({ app_surface: 'auth' })
initialized = true
return true
this.loadState = 'loading'
this.loadPromise = Promise.resolve()
.then(loader)
.then((adapter) => {
this.adapter = adapter
this.loadState = 'ready'
this.flush()
return true
})
.catch(() => {
// Content blockers commonly reject the provider's module request. The
// provider is optional, so discard queued telemetry and stay no-op.
this.pendingOperations.length = 0
this.loadState = 'unavailable'
return false
})
return this.loadPromise
}
capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (this.adapter) {
this.adapter.capture(event, properties, options)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'capture', event, properties, options })
}
identify(userId: string): void {
if (this.adapter) {
this.adapter.identify(userId)
return
}
if (this.loadState === 'loading')
this.enqueue({ kind: 'identify', userId })
}
private enqueue(operation: PendingOperation): void {
// A provider may remain slow indefinitely. Bound memory while preserving
// the newest auth funnel steps, which are the most useful after recovery.
if (this.pendingOperations.length === 100)
this.pendingOperations.shift()
this.pendingOperations.push(operation)
}
private flush(): void {
if (!this.adapter)
return
for (const operation of this.pendingOperations) {
if (operation.kind === 'identify')
this.adapter.identify(operation.userId)
else
this.adapter.capture(operation.event, operation.properties, operation.options)
}
this.pendingOperations.length = 0
}
}
const analytics = new AnalyticsClient()
/**
* Starts loading the optional provider adapter without exposing its SDK to
* pages or to the application's static module graph.
*/
export function loadAnalyticsAdapter(loader: () => Promise<AnalyticsAdapter>): Promise<boolean> {
return analytics.load(loader)
}
/**
@@ -55,29 +131,11 @@ export function initAuthAnalytics(): boolean {
* uses as `distinctId` (see `apps/server` product events forwarding).
*/
export function identifyAuthUser(userId: string): void {
if (!initialized)
return
posthog.identify(userId)
}
interface CaptureOptions {
/**
* Set when navigation immediately follows the capture call
* (`window.location.href = ...`). The batched queue would race the
* unload and drop the event; sendBeacon survives it.
*/
beforeNavigation?: boolean
analytics.identify(userId)
}
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (!initialized)
return
posthog.capture(
event,
properties,
options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined,
)
analytics.capture(event, properties, options)
}
/** Anonymous email-signup UI milestone; the server owns the registration fact. */
+13 -9
View File
@@ -45,18 +45,22 @@ export default defineConfig({
},
build: {
emptyOutDir: true,
manifest: true,
outDir: resolve(join(import.meta.dirname, 'dist')),
rolldownOptions: {
output: {
// NOTICE:
// Safari content blockers can reject application chunks solely because a
// semantic filename such as `analytics-*.js` matches a filtering rule.
// Root cause: auth analytics is currently shared by the entry and routes,
// so blocking that generated chunk prevents the Vue app from mounting.
// Source/context: `apps/ui-server-auth/src/modules/analytics.ts`.
// Removal condition: analytics is no longer an application-critical static
// dependency and blocked optional modules cannot prevent app startup.
chunkFileNames: 'assets/chunk-[hash].js',
chunkFileNames: (chunkInfo) => {
const containsAnalyticsModule = chunkInfo.moduleIds.some((moduleId) => {
const normalizedModuleId = moduleId.replaceAll('\\', '/').toLowerCase()
return normalizedModuleId.includes('analytics') || normalizedModuleId.includes('posthog')
})
// Keep analytics as the source-domain name, but explicitly map its
// public URL to a neutral chunk name that filter lists cannot infer.
return containsAnalyticsModule
? 'assets/chunk-[hash].js'
: 'assets/[name]-[hash].js'
},
},
},
sourcemap: true,