refactor(stage): infer conversation analytics surface

Centralize conversation event surface detection inside useAnalytics and keep chat UI call sites focused on business fields.

Signed-off-by: RainbowBird <git@luoling.moe>

Commit-Message-Assisted-by: Codex
This commit is contained in:
RainbowBird
2026-06-06 01:58:13 +08:00
parent c6b5906708
commit 30d57e7370
11 changed files with 62 additions and 38 deletions
@@ -115,6 +115,7 @@
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 appweb / desktop / pocket)按 `isStageTamagotchi()` 等选 project key - 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 appweb / desktop / pocket)按 `isStageTamagotchi()` 等选 project key
- Server 不接入 `posthog-node`。后端产品事件写 `product_events`,Grafana 展示低基数聚合;需要 PostHog revenue/person analytics 时优先用 PostHog Stripe source connector 或离线导入,不允许在 API 请求路径同步发 PostHog。 - 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()` - 前端 identity`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
- Conversation controls 事件的 `surface``packages/stage-ui/src/composables/use-analytics.ts` 统一按 runtime 推断,UI 调用点只传业务字段。
已埋点: 已埋点:
@@ -63,7 +63,7 @@ const {
trackChatMessageRetried, trackChatMessageRetried,
trackChatMessagesCleared, trackChatMessagesCleared,
} = useAnalytics() } = useAnalytics()
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('electron') const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
const latestImageEntries = computed(() => { const latestImageEntries = computed(() => {
if (!activeCardId.value) if (!activeCardId.value)
@@ -207,7 +207,6 @@ async function handleDeleteMessage(index: number) {
const message = messages.value[index] const message = messages.value[index]
await chatSyncStore.requestDeleteMessage({ index }) await chatSyncStore.requestDeleteMessage({ index })
trackChatMessageDeleted({ trackChatMessageDeleted({
surface: 'electron',
source: 'history', source: 'history',
message_role: message?.role ?? 'unknown', message_role: message?.role ?? 'unknown',
}) })
@@ -223,7 +222,6 @@ async function handleRetryMessage(index: number) {
index, index,
}) })
trackChatMessageRetried({ trackChatMessageRetried({
surface: 'electron',
source: 'history', source: 'history',
}) })
} }
@@ -232,7 +230,6 @@ async function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length const messageCount = messages.value.filter(message => message.role !== 'system').length
await chatSyncStore.requestCleanup() await chatSyncStore.requestCleanup()
trackChatMessagesCleared({ trackChatMessagesCleared({
surface: 'electron',
source: 'chat_controls', source: 'chat_controls',
message_count: messageCount, message_count: messageCount,
}) })
@@ -27,7 +27,6 @@ function handleDeleteMessage(index: number) {
const message = messages.value[index] const message = messages.value[index]
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index) messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
trackChatMessageDeleted({ trackChatMessageDeleted({
surface: 'web',
source: 'history', source: 'history',
message_role: message?.role ?? 'unknown', message_role: message?.role ?? 'unknown',
}) })
@@ -46,7 +46,6 @@ function handleDeleteMessage(index: number) {
const message = messages.value[index] const message = messages.value[index]
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index) messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
trackChatMessageDeleted({ trackChatMessageDeleted({
surface: 'mobile',
source: 'history', source: 'history',
message_role: message?.role ?? 'unknown', message_role: message?.role ?? 'unknown',
}) })
@@ -56,7 +55,6 @@ function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length const messageCount = messages.value.filter(message => message.role !== 'system').length
cleanupMessages() cleanupMessages()
trackChatMessagesCleared({ trackChatMessagesCleared({
surface: 'mobile',
source: 'chat_controls', source: 'chat_controls',
message_count: messageCount, message_count: messageCount,
}) })
@@ -94,7 +92,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } =
isStageTamagotchi, isStageTamagotchi,
}, },
) )
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('mobile') const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription() const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
async function handleSubmit() { async function handleSubmit() {
@@ -21,7 +21,6 @@ function handleCleanupMessages() {
const messageCount = messages.value.filter(message => message.role !== 'system').length const messageCount = messages.value.filter(message => message.role !== 'system').length
cleanupMessages() cleanupMessages()
trackChatMessagesCleared({ trackChatMessagesCleared({
surface: 'web',
source: 'chat_controls', source: 'chat_controls',
message_count: messageCount, message_count: messageCount,
}) })
@@ -60,7 +60,7 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription, au
isStageTamagotchi, isStageTamagotchi,
}, },
) )
const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton('web') const { showStopSpeakingButton, stopSpeakingFromChat } = useStopSpeakingButton()
async function handleSend() { async function handleSend() {
if (!messageInput.value.trim() || isComposing.value) { if (!messageInput.value.trim() || isComposing.value) {
@@ -33,7 +33,7 @@ describe('useStopSpeakingButton', () => {
it('shows the manual stop button only while the assistant is speaking', () => { it('shows the manual stop button only while the assistant is speaking', () => {
nowSpeaking.value = false nowSpeaking.value = false
const { showStopSpeakingButton } = useStopSpeakingButton('web') const { showStopSpeakingButton } = useStopSpeakingButton()
expect(showStopSpeakingButton.value).toBe(false) expect(showStopSpeakingButton.value).toBe(false)
@@ -46,13 +46,12 @@ describe('useStopSpeakingButton', () => {
requestStopSpeakingMock.mockClear() requestStopSpeakingMock.mockClear()
trackTtsStopClickedMock.mockClear() trackTtsStopClickedMock.mockClear()
const { stopSpeakingFromChat } = useStopSpeakingButton('mobile') const { stopSpeakingFromChat } = useStopSpeakingButton()
stopSpeakingFromChat() stopSpeakingFromChat()
expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-chat') expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-chat')
expect(trackTtsStopClickedMock).toHaveBeenCalledWith({ expect(trackTtsStopClickedMock).toHaveBeenCalledWith({
surface: 'mobile',
reason: 'manual-chat', reason: 'manual-chat',
}) })
}) })
@@ -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 { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio' import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control' import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control'
@@ -18,7 +16,7 @@ import { computed } from 'vue'
* Returns: * Returns:
* - Visibility state for the button and a click handler for manual chat stops. * - 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 { nowSpeaking } = storeToRefs(useSpeakingStore())
const speechOutputControlStore = useSpeechOutputControlStore() const speechOutputControlStore = useSpeechOutputControlStore()
const { trackTtsStopClicked } = useAnalytics() const { trackTtsStopClicked } = useAnalytics()
@@ -26,7 +24,7 @@ export function useStopSpeakingButton(surface: ConversationAnalyticsSurface) {
const showStopSpeakingButton = computed(() => nowSpeaking.value) const showStopSpeakingButton = computed(() => nowSpeaking.value)
function stopSpeakingFromChat() { function stopSpeakingFromChat() {
trackTtsStopClicked({ surface, reason: 'manual-chat' }) trackTtsStopClicked({ reason: 'manual-chat' })
speechOutputControlStore.requestStopSpeaking('manual-chat') speechOutputControlStore.requestStopSpeaking('manual-chat')
} }
@@ -157,7 +157,6 @@ async function selectSession(sessionId: string) {
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId) const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
if (sessionId !== activeSessionId.value && selectedRow) { if (sessionId !== activeSessionId.value && selectedRow) {
trackChatSessionSelected({ trackChatSessionSelected({
surface: isDesktop.value ? 'web' : 'mobile',
source: 'sessions_drawer', source: 'sessions_drawer',
message_count: (sessionMessages.value[sessionId] ?? []).filter(message => message.role !== 'system').length, message_count: (sessionMessages.value[sessionId] ?? []).filter(message => message.role !== 'system').length,
cloud_synced: !!selectedRow.meta.cloudChatId, cloud_synced: !!selectedRow.meta.cloudChatId,
@@ -5,11 +5,18 @@ import { useAnalytics } from './use-analytics'
const analyticsMocks = vi.hoisted(() => ({ const analyticsMocks = vi.hoisted(() => ({
ensurePosthogInitializedMock: vi.fn(() => true), ensurePosthogInitializedMock: vi.fn(() => true),
isStageCapacitorMock: vi.fn(() => false),
isStageTamagotchiMock: vi.fn(() => false),
isPosthogAvailableInBuildMock: vi.fn(() => true), isPosthogAvailableInBuildMock: vi.fn(() => true),
markFirstMessageTrackedMock: vi.fn(), markFirstMessageTrackedMock: vi.fn(),
posthogCaptureMock: vi.fn(), posthogCaptureMock: vi.fn(),
})) }))
vi.mock('@proj-airi/stage-shared', () => ({
isStageCapacitor: analyticsMocks.isStageCapacitorMock,
isStageTamagotchi: analyticsMocks.isStageTamagotchiMock,
}))
vi.mock('posthog-js', () => ({ vi.mock('posthog-js', () => ({
default: { default: {
capture: analyticsMocks.posthogCaptureMock, capture: analyticsMocks.posthogCaptureMock,
@@ -56,56 +63,57 @@ describe('useAnalytics conversation product events', () => {
analyticsMocks.posthogCaptureMock.mockClear() analyticsMocks.posthogCaptureMock.mockClear()
analyticsMocks.markFirstMessageTrackedMock.mockClear() analyticsMocks.markFirstMessageTrackedMock.mockClear()
analyticsMocks.ensurePosthogInitializedMock.mockClear() analyticsMocks.ensurePosthogInitializedMock.mockClear()
analyticsMocks.isStageCapacitorMock.mockReset()
analyticsMocks.isStageTamagotchiMock.mockReset()
analyticsMocks.isStageCapacitorMock.mockReturnValue(false)
analyticsMocks.isStageTamagotchiMock.mockReturnValue(false)
analyticsMocks.isPosthogAvailableInBuildMock.mockClear() 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() const analytics = useAnalytics()
analytics.trackTtsStopClicked({ analytics.trackTtsStopClicked({
surface: 'mobile',
reason: 'manual-chat', reason: 'manual-chat',
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', { expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('tts_stop_clicked', {
surface: 'mobile', surface: 'web',
reason: 'manual-chat', 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() const analytics = useAnalytics()
analytics.trackChatSessionSelected({ analytics.trackChatSessionSelected({
surface: 'web',
source: 'sessions_drawer', source: 'sessions_drawer',
message_count: 4, message_count: 4,
cloud_synced: true, cloud_synced: true,
}) })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', { expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('chat_session_selected', {
surface: 'web', surface: 'mobile',
source: 'sessions_drawer', source: 'sessions_drawer',
message_count: 4, message_count: 4,
cloud_synced: true, 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() const analytics = useAnalytics()
analytics.trackChatMessageDeleted({ analytics.trackChatMessageDeleted({
surface: 'electron',
source: 'history', source: 'history',
message_role: 'assistant', message_role: 'assistant',
}) })
analytics.trackChatMessagesCleared({ analytics.trackChatMessagesCleared({
surface: 'electron',
source: 'chat_controls', source: 'chat_controls',
message_count: 3, message_count: 3,
}) })
analytics.trackChatMessageRetried({ analytics.trackChatMessageRetried({
surface: 'electron',
source: 'history', source: 'history',
}) })
@@ -1,5 +1,6 @@
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -19,6 +20,16 @@ export type ConversationAnalyticsSurface = 'web' | 'mobile' | 'electron'
*/ */
export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer' export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer'
function getConversationAnalyticsSurface(): ConversationAnalyticsSurface {
if (isStageTamagotchi())
return 'electron'
if (isStageCapacitor())
return 'mobile'
return 'web'
}
export function useAnalytics() { export function useAnalytics() {
const analyticsStore = useSharedAnalyticsStore() const analyticsStore = useSharedAnalyticsStore()
const settingsAnalytics = useSettingsAnalytics() const settingsAnalytics = useSettingsAnalytics()
@@ -219,34 +230,49 @@ export function useAnalytics() {
// ─── Conversation action events ───────────────────────────────────── // ─── Conversation action events ─────────────────────────────────────
function trackTtsStopClicked(properties: { surface: ConversationAnalyticsSurface, reason: 'manual-chat' }) { function trackTtsStopClicked(properties: { reason: 'manual-chat' }) {
if (!canCapture()) if (!canCapture())
return 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()) if (!canCapture())
return 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()) if (!canCapture())
return 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()) if (!canCapture())
return 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()) if (!canCapture())
return return
posthog.capture('chat_message_retried', properties) posthog.capture('chat_message_retried', {
...properties,
surface: getConversationAnalyticsSurface(),
})
} }
// ─── STT events ────────────────────────────────────────────────────── // ─── STT events ──────────────────────────────────────────────────────