diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md index c12a0c03e..9a0229a4d 100644 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ b/apps/server/docs/ai-context/metrics-ownership.md @@ -115,6 +115,7 @@ - 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 app(web / desktop / pocket)按 `isStageTamagotchi()` 等选 project key - Server 不接入 `posthog-node`。后端产品事件写 `product_events`,Grafana 展示低基数聚合;需要 PostHog revenue/person analytics 时优先用 PostHog Stripe source connector 或离线导入,不允许在 API 请求路径同步发 PostHog。 - 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()` +- Conversation controls 事件的 `surface` 由 `packages/stage-ui/src/composables/use-analytics.ts` 统一按 runtime 推断,UI 调用点只传业务字段。 已埋点: diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue index 1b3b350b5..55f612d23 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue @@ -63,7 +63,7 @@ const { trackChatMessageRetried, trackChatMessagesCleared, } = useAnalytics() -const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('electron') +const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton() const latestImageEntries = computed(() => { if (!activeCardId.value) @@ -207,7 +207,6 @@ async function handleDeleteMessage(index: number) { const message = messages.value[index] await chatSyncStore.requestDeleteMessage({ index }) trackChatMessageDeleted({ - surface: 'electron', source: 'history', message_role: message?.role ?? 'unknown', }) @@ -223,7 +222,6 @@ async function handleRetryMessage(index: number) { index, }) trackChatMessageRetried({ - surface: 'electron', source: 'history', }) } @@ -232,7 +230,6 @@ async function handleCleanupMessages() { const messageCount = messages.value.filter(message => message.role !== 'system').length await chatSyncStore.requestCleanup() trackChatMessagesCleared({ - surface: 'electron', source: 'chat_controls', message_count: messageCount, }) diff --git a/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue index e515ed2cb..62179601d 100644 --- a/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/InteractiveArea.vue @@ -27,7 +27,6 @@ function handleDeleteMessage(index: number) { const message = messages.value[index] messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index) trackChatMessageDeleted({ - surface: 'web', source: 'history', message_role: message?.role ?? 'unknown', }) diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index 81b6323f5..f038e08c3 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -46,7 +46,6 @@ function handleDeleteMessage(index: number) { const message = messages.value[index] messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index) trackChatMessageDeleted({ - surface: 'mobile', source: 'history', message_role: message?.role ?? 'unknown', }) @@ -56,7 +55,6 @@ function handleCleanupMessages() { const messageCount = messages.value.filter(message => message.role !== 'system').length cleanupMessages() trackChatMessagesCleared({ - surface: 'mobile', source: 'chat_controls', message_count: messageCount, }) @@ -94,7 +92,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } = isStageTamagotchi, }, ) -const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('mobile') +const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton() const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription() async function handleSubmit() { diff --git a/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue b/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue index 1871be0c4..185ea66e5 100644 --- a/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue +++ b/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue @@ -21,7 +21,6 @@ function handleCleanupMessages() { const messageCount = messages.value.filter(message => message.role !== 'system').length cleanupMessages() trackChatMessagesCleared({ - surface: 'web', source: 'chat_controls', message_count: messageCount, }) diff --git a/packages/stage-layouts/src/components/Widgets/ChatArea.vue b/packages/stage-layouts/src/components/Widgets/ChatArea.vue index bd0300d18..4d88feb60 100644 --- a/packages/stage-layouts/src/components/Widgets/ChatArea.vue +++ b/packages/stage-layouts/src/components/Widgets/ChatArea.vue @@ -60,7 +60,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription, au isStageTamagotchi, }, ) -const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('web') +const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton() async function handleSend() { if (!messageInput.value.trim() || isComposing.value) { diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts index 631e1902a..776a6ef1a 100644 --- a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts +++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts @@ -33,7 +33,7 @@ describe('useStopSpeakingButton', () => { it('shows the manual stop button only while the assistant is speaking', () => { nowSpeaking.value = false - const { showStopSpeakingButton } = useStopSpeakingButton('web') + const { showStopSpeakingButton } = useStopSpeakingButton() expect(showStopSpeakingButton.value).toBe(false) @@ -46,13 +46,12 @@ describe('useStopSpeakingButton', () => { requestStopSpeakingMock.mockClear() trackTtsStopClickedMock.mockClear() - const { stopSpeakingFromChat } = useStopSpeakingButton('mobile') + const { stopSpeakingFromChat } = useStopSpeakingButton() stopSpeakingFromChat() expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-chat') expect(trackTtsStopClickedMock).toHaveBeenCalledWith({ - surface: 'mobile', reason: 'manual-chat', }) }) diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts index ea35562dc..8c64955d4 100644 --- a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts +++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts @@ -1,5 +1,3 @@ -import type { ConversationAnalyticsSurface } from '@proj-airi/stage-ui/composables/use-analytics' - import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics' import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio' import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control' @@ -18,7 +16,7 @@ import { computed } from 'vue' * Returns: * - Visibility state for the button and a click handler for manual chat stops. */ -export function useStopSpeakingButton(surface: ConversationAnalyticsSurface) { +export function useStopSpeakingButton() { const { nowSpeaking } = storeToRefs(useSpeakingStore()) const speechOutputControlStore = useSpeechOutputControlStore() const { trackTtsStopClicked } = useAnalytics() @@ -26,7 +24,7 @@ export function useStopSpeakingButton(surface: ConversationAnalyticsSurface) { const showStopSpeakingButton = computed(() => nowSpeaking.value) function stopSpeakingFromChat() { - trackTtsStopClicked({ surface, reason: 'manual-chat' }) + trackTtsStopClicked({ reason: 'manual-chat' }) speechOutputControlStore.requestStopSpeaking('manual-chat') } diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue index 2967ade77..306a6c137 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue @@ -157,7 +157,6 @@ async function selectSession(sessionId: string) { const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId) if (sessionId !== activeSessionId.value && selectedRow) { trackChatSessionSelected({ - surface: isDesktop.value ? 'web' : 'mobile', source: 'sessions_drawer', message_count: (sessionMessages.value[sessionId] ?? []).filter(message => message.role !== 'system').length, cloud_synced: !!selectedRow.meta.cloudChatId, diff --git a/packages/stage-ui/src/composables/use-analytics.test.ts b/packages/stage-ui/src/composables/use-analytics.test.ts index d0ca05763..73d8460ec 100644 --- a/packages/stage-ui/src/composables/use-analytics.test.ts +++ b/packages/stage-ui/src/composables/use-analytics.test.ts @@ -5,11 +5,18 @@ import { useAnalytics } from './use-analytics' const analyticsMocks = vi.hoisted(() => ({ ensurePosthogInitializedMock: vi.fn(() => true), + isStageCapacitorMock: vi.fn(() => false), + isStageTamagotchiMock: vi.fn(() => false), isPosthogAvailableInBuildMock: vi.fn(() => true), markFirstMessageTrackedMock: vi.fn(), posthogCaptureMock: vi.fn(), })) +vi.mock('@proj-airi/stage-shared', () => ({ + isStageCapacitor: analyticsMocks.isStageCapacitorMock, + isStageTamagotchi: analyticsMocks.isStageTamagotchiMock, +})) + vi.mock('posthog-js', () => ({ default: { capture: analyticsMocks.posthogCaptureMock, @@ -56,56 +63,57 @@ describe('useAnalytics conversation product events', () => { analyticsMocks.posthogCaptureMock.mockClear() analyticsMocks.markFirstMessageTrackedMock.mockClear() analyticsMocks.ensurePosthogInitializedMock.mockClear() + analyticsMocks.isStageCapacitorMock.mockReset() + analyticsMocks.isStageTamagotchiMock.mockReset() + analyticsMocks.isStageCapacitorMock.mockReturnValue(false) + analyticsMocks.isStageTamagotchiMock.mockReturnValue(false) analyticsMocks.isPosthogAvailableInBuildMock.mockClear() }) - it('tracks manual TTS stop clicks with the UI surface and reason', () => { + it('infers the web surface for browser conversation actions', () => { const analytics = useAnalytics() analytics.trackTtsStopClicked({ - surface: 'mobile', reason: 'manual-chat', }) expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', { - surface: 'mobile', + surface: 'web', reason: 'manual-chat', }) }) - it('tracks chat session selection from the sessions drawer', () => { + it('infers the mobile surface for capacitor conversation actions', () => { + analyticsMocks.isStageCapacitorMock.mockReturnValue(true) const analytics = useAnalytics() analytics.trackChatSessionSelected({ - surface: 'web', source: 'sessions_drawer', message_count: 4, cloud_synced: true, }) expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', { - surface: 'web', + surface: 'mobile', source: 'sessions_drawer', message_count: 4, cloud_synced: true, }) }) - it('tracks destructive and recovery chat message actions', () => { + it('infers the electron surface for destructive and recovery chat message actions', () => { + analyticsMocks.isStageTamagotchiMock.mockReturnValue(true) const analytics = useAnalytics() analytics.trackChatMessageDeleted({ - surface: 'electron', source: 'history', message_role: 'assistant', }) analytics.trackChatMessagesCleared({ - surface: 'electron', source: 'chat_controls', message_count: 3, }) analytics.trackChatMessageRetried({ - surface: 'electron', source: 'history', }) diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index ac4e99128..9bdcda375 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -1,5 +1,6 @@ import posthog from 'posthog-js' +import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared' import { computed } from 'vue' import { useI18n } from 'vue-i18n' @@ -19,6 +20,16 @@ export type ConversationAnalyticsSurface = 'web' | 'mobile' | 'electron' */ export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer' +function getConversationAnalyticsSurface(): ConversationAnalyticsSurface { + if (isStageTamagotchi()) + return 'electron' + + if (isStageCapacitor()) + return 'mobile' + + return 'web' +} + export function useAnalytics() { const analyticsStore = useSharedAnalyticsStore() const settingsAnalytics = useSettingsAnalytics() @@ -219,34 +230,49 @@ export function useAnalytics() { // ─── Conversation action events ───────────────────────────────────── - function trackTtsStopClicked(properties: { surface: ConversationAnalyticsSurface, reason: 'manual-chat' }) { + function trackTtsStopClicked(properties: { reason: 'manual-chat' }) { if (!canCapture()) return - posthog.capture('tts_stop_clicked', properties) + posthog.capture('tts_stop_clicked', { + ...properties, + surface: getConversationAnalyticsSurface(), + }) } - function trackChatSessionSelected(properties: { surface: ConversationAnalyticsSurface, source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) { + function trackChatSessionSelected(properties: { source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) { if (!canCapture()) return - posthog.capture('chat_session_selected', properties) + posthog.capture('chat_session_selected', { + ...properties, + surface: getConversationAnalyticsSurface(), + }) } - function trackChatMessageDeleted(properties: { surface: ConversationAnalyticsSurface, source: 'history', message_role: string }) { + function trackChatMessageDeleted(properties: { source: 'history', message_role: string }) { if (!canCapture()) return - posthog.capture('chat_message_deleted', properties) + posthog.capture('chat_message_deleted', { + ...properties, + surface: getConversationAnalyticsSurface(), + }) } - function trackChatMessagesCleared(properties: { surface: ConversationAnalyticsSurface, source: 'chat_controls', message_count: number }) { + function trackChatMessagesCleared(properties: { source: 'chat_controls', message_count: number }) { if (!canCapture()) return - posthog.capture('chat_messages_cleared', properties) + posthog.capture('chat_messages_cleared', { + ...properties, + surface: getConversationAnalyticsSurface(), + }) } - function trackChatMessageRetried(properties: { surface: ConversationAnalyticsSurface, source: 'history' }) { + function trackChatMessageRetried(properties: { source: 'history' }) { if (!canCapture()) return - posthog.capture('chat_message_retried', properties) + posthog.capture('chat_message_retried', { + ...properties, + surface: getConversationAnalyticsSurface(), + }) } // ─── STT events ──────────────────────────────────────────────────────