diff --git a/packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts b/packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts index 05e106235..7f88545fd 100644 --- a/packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts +++ b/packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts @@ -500,9 +500,9 @@ describe('createChatOrchestratorRuntime', () => { model: 'gpt-test', chatProvider: provider, input: { - type: 'input:text', + type: 'input:text:voice', data: { - text: 'hello from voice', + transcription: 'hello from voice', }, }, }) @@ -579,6 +579,35 @@ describe('createChatOrchestratorRuntime', () => { expect(harness.telemetry.chatActivationFailed).toEqual([]) }) + // Review: https://github.com/moeru-ai/airi/pull/2325 + it('pr #2325 treats input:text metadata as text telemetry', async () => { + const harness = createHarness() + + await harness.runtime.ingest('hello from text input', { + model: 'gpt-test', + chatProvider: provider, + input: { + type: 'input:text', + data: { + text: 'hello from text input', + }, + }, + }) + + expect(harness.telemetry.messageSendStarted).toEqual([ + expect.objectContaining({ source: 'text' }), + ]) + expect(harness.telemetry.llmRequestStarted).toEqual([ + expect.objectContaining({ hasVoice: false }), + ]) + expect(harness.telemetry.messageRound).toEqual([ + expect.objectContaining({ hasVoice: false }), + ]) + expect(harness.userAppended).toEqual([ + expect.objectContaining({ source: 'text' }), + ]) + }) + // ROOT CAUSE: // // Activation callbacks were emitted for every chat round, so production diff --git a/packages/core-agent/src/runtime/chat-orchestrator-runtime.ts b/packages/core-agent/src/runtime/chat-orchestrator-runtime.ts index c4058a890..20ef3a976 100644 --- a/packages/core-agent/src/runtime/chat-orchestrator-runtime.ts +++ b/packages/core-agent/src/runtime/chat-orchestrator-runtime.ts @@ -519,7 +519,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps) id: assistantMessageId, } beginStream(sessionId, buildingMessage) - const sendSource = options.input ? 'voice' : 'text' + const hasVoice = options.input?.type === 'input:voice' + || options.input?.type === 'input:text:voice' + const sendSource = hasVoice ? 'voice' : 'text' const activeProvider = deps.getActiveProvider?.() ?? '' // The user message is the durable start of a round, so its ID also serves // as the correlation key for every telemetry milestone emitted by it. @@ -754,7 +756,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps) ...correlation, model: options.model, provider: deps.getActiveProvider() || 'unknown', - hasVoice: !!options.input, + hasVoice, }) await deps.llm.stream(options.model, options.chatProvider, newMessages as Message[], { @@ -916,7 +918,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps) deps.onMessageRound?.({ ...correlation, durationMs, - hasVoice: !!options.input, + hasVoice, model: options.model, inputTokens: generationUsage.inputTokens, outputTokens: generationUsage.outputTokens, diff --git a/packages/stage-pages/src/pages/settings/modules/consciousness.vue b/packages/stage-pages/src/pages/settings/modules/consciousness.vue index 57e541ced..e567934c4 100644 --- a/packages/stage-pages/src/pages/settings/modules/consciousness.vue +++ b/packages/stage-pages/src/pages/settings/modules/consciousness.vue @@ -28,34 +28,12 @@ const { } = storeToRefs(consciousnessStore) const { t } = useI18n() -const { trackOfficialProviderSelected, trackProviderClick } = useAnalytics() +const { trackModelSwitched, trackProviderClick } = useAnalytics() -/** - * Tracks explicit official chat-provider selection from settings. - */ -function trackOfficialProviderSelection(providerId: string, modelId: string) { - if (!providerId.startsWith('official-provider')) - return - - trackOfficialProviderSelected({ - provider_id: providerId, - provider_mode: 'official', - source: 'settings', - auto_selected: false, - model_id: modelId || 'unknown', - }) -} - -watch(activeProvider, async (provider, oldProvider) => { +watch(activeProvider, async (provider) => { if (!provider) return - // The consciousness store clears the model selection on provider changes; - // the page only tracks the selection and loads the new provider's catalog. - if (oldProvider !== undefined && oldProvider !== provider) { - trackOfficialProviderSelection(provider, activeModel.value) - } - await consciousnessStore.loadModelsForProvider(provider) }, { immediate: true }) @@ -67,6 +45,14 @@ function updateCustomModelName(value: string) { customModelName.value = value } +function selectModel(modelId: string) { + const previousModelId = activeModel.value + activeModel.value = modelId + + if (previousModelId !== modelId) + trackModelSwitched(previousModelId || 'none', modelId) +} + function handleDeleteProvider(providerId: string) { if (activeProvider.value === providerId) { activeProvider.value = '' @@ -217,8 +203,8 @@ function handleDeleteProvider(providerId: string) { @@ -236,8 +223,8 @@ function handleDeleteProvider(providerId: string) { diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 5a16961cb..7a1fecada 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -32,11 +32,9 @@ import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } fr import StageRenderError from './stage-render-error.vue' import { useSettingsLive2d } from '../../../../stage-ui-live2d/src/composables/live2d/live2d' -import { useAnalytics } from '../../composables/use-analytics' import { useDuckDb } from '../../composables/use-duck-db' import { useIOTraceBridge } from '../../composables/use-io-trace-bridge' import { initIOTracer } from '../../composables/use-io-tracer' -import { useSpeechPipelineAnalytics } from '../../composables/use-speech-pipeline-analytics' import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions' import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official' @@ -204,8 +202,6 @@ const speechStore = useSpeechStore() const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice, pitch } = storeToRefs(speechStore) const activeCardId = computed(() => activeCard.value?.name ?? 'default') const speechRuntimeStore = useSpeechRuntimeStore() -const { trackOfficialTtsAutoEnabled } = useAnalytics() -let officialAutoTtsTrackedForTurn = false const backgroundStore = useBackgroundStore() const { activeBackgroundUrl } = storeToRefs(backgroundStore) @@ -372,11 +368,6 @@ async function playFunction(item: Parameters({ tts: async (request, signal) => { if (signal.aborted) @@ -536,7 +509,6 @@ const speechPipeline = createSpeechPipeline({ return null const audioBuffer = await audioContext.decodeAudioData(res) - trackOfficialAutoTtsForTurn(model) return audioBuffer } catch (err) { @@ -561,7 +533,6 @@ const speechPipeline = createSpeechPipeline({ initIOTracer() useIOTraceBridge(speechPipeline) -useSpeechPipelineAnalytics() void speechRuntimeStore.registerHost(speechPipeline) speechPipeline.on('onSpecial', (segment) => { @@ -828,7 +799,6 @@ watch(speechMuted, (muted) => { }, { immediate: true }) chatHookCleanups.push(onBeforeMessageComposed(async (_message, context) => { - officialAutoTtsTrackedForTurn = false playbackManager.stopAll('new-message') resetAssistantSpeechSurface('new-message') diff --git a/packages/stage-ui/src/composables/audio/audio-device.test.ts b/packages/stage-ui/src/composables/audio/audio-device.test.ts index 9f054ca31..4bdf1f1a2 100644 --- a/packages/stage-ui/src/composables/audio/audio-device.test.ts +++ b/packages/stage-ui/src/composables/audio/audio-device.test.ts @@ -5,9 +5,7 @@ const audioDeviceMock = vi.hoisted(() => ({ ensurePermissions: vi.fn(), startStream: vi.fn(), stopStream: vi.fn(), - trackAudioDeviceUnavailable: vi.fn(), trackMicrophonePermissionDenied: vi.fn(), - trackMicrophonePermissionRequested: vi.fn(), })) vi.mock('@vueuse/core', async () => { @@ -31,9 +29,7 @@ vi.mock('@vueuse/core', async () => { vi.mock('../use-analytics', () => ({ useAnalytics: () => ({ - trackAudioDeviceUnavailable: audioDeviceMock.trackAudioDeviceUnavailable, trackMicrophonePermissionDenied: audioDeviceMock.trackMicrophonePermissionDenied, - trackMicrophonePermissionRequested: audioDeviceMock.trackMicrophonePermissionRequested, }), })) @@ -42,9 +38,7 @@ describe('useAudioDevice analytics lifecycle', () => { if (audioDeviceMock.audioInputsRef) audioDeviceMock.audioInputsRef.value = [] audioDeviceMock.ensurePermissions.mockReset() - audioDeviceMock.trackAudioDeviceUnavailable.mockReset() audioDeviceMock.trackMicrophonePermissionDenied.mockReset() - audioDeviceMock.trackMicrophonePermissionRequested.mockReset() vi.spyOn(console, 'error').mockImplementation(() => {}) }) @@ -65,22 +59,13 @@ describe('useAudioDevice analytics lifecycle', () => { await expect(askPermission()).rejects.toThrow(permissionError) - expect(audioDeviceMock.trackMicrophonePermissionRequested).toHaveBeenCalledWith({ - stt_provider_id: 'unknown', - }) expect(audioDeviceMock.trackMicrophonePermissionDenied).toHaveBeenCalledWith({ stt_provider_id: 'unknown', error_code: 'permission_denied', }) - expect(audioDeviceMock.trackAudioDeviceUnavailable).not.toHaveBeenCalled() }) - /** - * @example - * await askPermission() - * expect(trackAudioDeviceUnavailable).toHaveBeenCalledWith(expect.objectContaining({ error_code: 'device_unavailable' })) - */ - it('tracks successful permission requests that still expose no microphone devices', async () => { + it('does not turn an empty device list into a product event', async () => { const { useAudioDevice } = await import('./audio-device') audioDeviceMock.ensurePermissions.mockResolvedValue(undefined) @@ -88,13 +73,6 @@ describe('useAudioDevice analytics lifecycle', () => { await askPermission() - expect(audioDeviceMock.trackMicrophonePermissionRequested).toHaveBeenCalledWith({ - stt_provider_id: 'unknown', - }) - expect(audioDeviceMock.trackAudioDeviceUnavailable).toHaveBeenCalledWith({ - stt_provider_id: 'unknown', - error_code: 'device_unavailable', - }) expect(audioDeviceMock.trackMicrophonePermissionDenied).not.toHaveBeenCalled() }) }) diff --git a/packages/stage-ui/src/composables/audio/audio-device.ts b/packages/stage-ui/src/composables/audio/audio-device.ts index 1db1290f8..fdd522bca 100644 --- a/packages/stage-ui/src/composables/audio/audio-device.ts +++ b/packages/stage-ui/src/composables/audio/audio-device.ts @@ -40,11 +40,7 @@ function audioDeviceErrorCode(error: unknown): 'permission_denied' | 'device_una * Provides microphone device selection, permission requests, and audio stream lifecycle state. */ export function useAudioDevice(requestPermission: boolean = false) { - const { - trackAudioDeviceUnavailable, - trackMicrophonePermissionDenied, - trackMicrophonePermissionRequested, - } = useAnalytics() + const { trackMicrophonePermissionDenied } = useAnalytics() const { devices, audioInputs, @@ -94,9 +90,6 @@ export function useAudioDevice(requestPermission: boolean = false) { }) async function askPermission() { - if (!permissionGranted.value) - trackMicrophonePermissionRequested({ stt_provider_id: UNKNOWN_STT_PROVIDER_ID }) - try { const granted = await ensurePermissions() @@ -110,12 +103,6 @@ export function useAudioDevice(requestPermission: boolean = false) { } selectAvailableAudioInput() - if (audioInputs.value.length <= 0) { - trackAudioDeviceUnavailable({ - stt_provider_id: UNKNOWN_STT_PROVIDER_ID, - error_code: 'device_unavailable', - }) - } } catch (error) { const errorCode = audioDeviceErrorCode(error) @@ -125,12 +112,6 @@ export function useAudioDevice(requestPermission: boolean = false) { error_code: errorCode, }) } - else { - trackAudioDeviceUnavailable({ - stt_provider_id: UNKNOWN_STT_PROVIDER_ID, - error_code: errorCode, - }) - } console.error('Error ensuring permissions:', error) throw error } diff --git a/packages/stage-ui/src/composables/use-analytics.test.ts b/packages/stage-ui/src/composables/use-analytics.test.ts index 99630c5c4..17cb9fd8b 100644 --- a/packages/stage-ui/src/composables/use-analytics.test.ts +++ b/packages/stage-ui/src/composables/use-analytics.test.ts @@ -215,80 +215,6 @@ describe('useAnalytics conversation product events', () => { }) }) - /** - * @example - * analytics.trackChatActivationStarted({ conversation_id: 'session-1', round_id: 'round-1', turn_index: 1, provider_mode: 'official', provider_id: 'official-provider', model_id: 'gpt-test', source: 'text' }) - * expect(posthog.capture).toHaveBeenCalledWith('chat_activation_started', expect.objectContaining({ app_surface: 'web' })) - */ - it('emits chat activation milestones with inferred surface and normalized fields', () => { - const analytics = useAnalytics() - - analytics.trackChatActivationStarted({ - conversation_id: 'session-1', - round_id: 'round-1', - turn_index: 1, - provider_mode: 'official', - provider_id: 'official-provider', - model_id: 'gpt-test', - source: 'text', - }) - analytics.trackChatActivationSucceeded({ - conversation_id: 'session-1', - round_id: 'round-1', - turn_index: 1, - provider_mode: 'official', - provider_id: 'official-provider', - model_id: 'gpt-test', - time_to_first_message_ms: 1200, - source: 'voice', - }) - analytics.trackChatActivationFailed({ - conversation_id: 'session-1', - round_id: 'round-1', - turn_index: 1, - provider_mode: 'custom', - provider_id: 'openai-compatible', - model_id: 'custom', - error_code: 'provider_error', - failure_stage: 'llm_response', - source: 'voice', - }) - - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'chat_activation_started', { - app_surface: 'web', - conversation_id: 'session-1', - round_id: 'round-1', - turn_index: 1, - provider_mode: 'official', - provider_id: 'official-provider', - model_id: 'gpt-test', - source: 'text', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'chat_activation_succeeded', { - app_surface: 'web', - conversation_id: 'session-1', - round_id: 'round-1', - turn_index: 1, - provider_mode: 'official', - provider_id: 'official-provider', - model_id: 'gpt-test', - time_to_first_message_ms: 1200, - source: 'voice', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'chat_activation_failed', { - app_surface: 'web', - conversation_id: 'session-1', - round_id: 'round-1', - turn_index: 1, - provider_mode: 'custom', - provider_id: 'openai-compatible', - model_id: 'custom', - error_code: 'provider_error', - failure_stage: 'llm_response', - source: 'voice', - }) - }) - /** * @example * analytics.trackVoiceSelected({ tts_provider_id: 'official-provider', tts_model_id: 'stepfun/tts', voice_id: 'voice-1', voice_type: 'official_selected', source: 'settings' }) @@ -329,6 +255,8 @@ describe('useAnalytics conversation product events', () => { tts_provider_id: 'official-provider', tts_model_id: 'stepfun/tts', source: 'settings', + trigger_method: 'selection', + trigger_type: 'user_action', }) expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'voice_selected', { app_surface: 'web', @@ -356,51 +284,6 @@ describe('useAnalytics conversation product events', () => { }) }) - /** - * @example - * analytics.trackOfficialProviderSelected({ provider_id: 'official-provider', provider_mode: 'official', source: 'default_auto', auto_selected: true }) - * expect(posthog.capture).toHaveBeenCalledWith('official_provider_selected', expect.objectContaining({ auto_selected: true })) - */ - it('emits official provider and second-turn operating events with bounded fields', () => { - const analytics = useAnalytics() - - analytics.trackOfficialProviderSelected({ - provider_id: 'official-provider', - provider_mode: 'official', - source: 'default_auto', - auto_selected: true, - model_id: 'chat-auto', - }) - analytics.trackSecondTurnStarted({ - conversation_id: 'session-1', - provider_id: 'official-provider', - provider_mode: 'official', - model_id: 'chat-auto', - round_id: 'round-2', - source: 'text', - turn_index: 2, - }) - - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'official_provider_selected', { - app_surface: 'web', - provider_id: 'official-provider', - provider_mode: 'official', - source: 'default_auto', - auto_selected: true, - model_id: 'chat-auto', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'second_turn_started', { - app_surface: 'web', - conversation_id: 'session-1', - provider_id: 'official-provider', - provider_mode: 'official', - model_id: 'chat-auto', - round_id: 'round-2', - source: 'text', - turn_index: 2, - }) - }) - /** * @example * analytics.trackOfficialTtsExposed({ source: 'post_first_chat', tts_provider_id: 'official-provider-speech', tts_model_id: 'stepfun/tts' }) @@ -429,13 +312,6 @@ describe('useAnalytics conversation product events', () => { source: 'manual_preview', duration_ms: 320, }) - analytics.trackOfficialTtsAutoEnabled({ - tts_provider_id: 'official-provider-speech', - tts_model_id: 'stepfun/tts', - source: 'settings', - enabled: true, - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'official_tts_exposed', { app_surface: 'web', tts_provider_id: 'official-provider-speech', @@ -459,13 +335,6 @@ describe('useAnalytics conversation product events', () => { source: 'manual_preview', duration_ms: 320, }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'official_tts_auto_enabled', { - app_surface: 'web', - tts_provider_id: 'official-provider-speech', - tts_model_id: 'stepfun/tts', - source: 'settings', - enabled: true, - }) }) it('emits paywall exposure with balance bucket for monetization funnels', () => { @@ -519,23 +388,16 @@ describe('useAnalytics conversation product events', () => { * analytics.trackMicrophonePermissionDenied({ stt_provider_id: 'browser-web-speech-api' }) * expect(posthog.capture).toHaveBeenCalledWith('microphone_permission_denied', expect.objectContaining({ app_surface: 'web' })) */ - it('emits voice input friction events with low-cardinality error fields', () => { + it('emits one voice action and only user-relevant outcome events', () => { const analytics = useAnalytics() analytics.trackVoiceInputStarted({ stt_provider_id: 'browser-web-speech-api', }) - analytics.trackMicrophonePermissionRequested({ - stt_provider_id: 'browser-web-speech-api', - }) analytics.trackMicrophonePermissionDenied({ stt_provider_id: 'browser-web-speech-api', error_code: 'permission_denied', }) - analytics.trackAudioDeviceUnavailable({ - stt_provider_id: 'browser-web-speech-api', - error_code: 'device_unavailable', - }) analytics.trackVoiceInputCancelled({ stt_provider_id: 'browser-web-speech-api', duration_ms: 420, @@ -544,95 +406,84 @@ describe('useAnalytics conversation product events', () => { expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'voice_input_started', { app_surface: 'web', stt_provider_id: 'browser-web-speech-api', + trigger_method: 'voice', + trigger_type: 'user_action', }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'voice_input_used', { - app_surface: 'web', - stt_provider_id: 'browser-web-speech-api', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'microphone_permission_requested', { - app_surface: 'web', - stt_provider_id: 'browser-web-speech-api', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'microphone_permission_denied', { + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'microphone_permission_denied', { app_surface: 'web', stt_provider_id: 'browser-web-speech-api', error_code: 'permission_denied', + trigger_method: 'voice', + trigger_type: 'user_flow_result', }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'audio_device_unavailable', { - app_surface: 'web', - stt_provider_id: 'browser-web-speech-api', - error_code: 'device_unavailable', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'voice_input_cancelled', { + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'voice_input_cancelled', { app_surface: 'web', stt_provider_id: 'browser-web-speech-api', duration_ms: 420, + trigger_method: 'voice', + trigger_type: 'user_flow_result', }) + expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(3) }) /** * @example - * analytics.trackProviderConfigFailed({ provider_id: 'openai-compatible', provider_mode: 'custom', step: 'settings_auto_validate', error_code: 'validation_failed', duration_ms: 32 }) - * expect(posthog.capture).toHaveBeenCalledWith('provider_config_failed', expect.objectContaining({ error_code: 'validation_failed' })) + * analytics.trackProviderConnectionTestCompleted({ provider_id: 'openai-compatible', provider_mode: 'custom', success: false, error_code: 'validation_failed', duration_ms: 32 }) + * expect(posthog.capture).toHaveBeenCalledWith('provider_connection_test_completed', expect.objectContaining({ error_code: 'validation_failed' })) */ - it('emits provider configuration health events with bounded fields', () => { + it('emits one manual provider test action and one result', () => { const analytics = useAnalytics() - analytics.trackProviderConfigStarted({ - provider_id: 'openai-compatible', - provider_mode: 'custom', - step: 'settings_auto_validate', - }) - analytics.trackProviderConfigSucceeded({ + analytics.trackProviderConnectionTestStarted({ provider_id: 'official-provider', provider_mode: 'official', - step: 'manual_chat_ping', - duration_ms: 18, }) - analytics.trackProviderConfigFailed({ - provider_id: 'openai-compatible', - provider_mode: 'custom', - step: 'settings_auto_validate', - error_code: 'validation_failed', - duration_ms: 32, - }) - - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_config_started', { - app_surface: 'web', - provider_id: 'openai-compatible', - provider_mode: 'custom', - step: 'settings_auto_validate', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'provider_config_succeeded', { - app_surface: 'web', + analytics.trackProviderConnectionTestCompleted({ provider_id: 'official-provider', provider_mode: 'official', - step: 'manual_chat_ping', - duration_ms: 18, - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'provider_config_completed', { - app_surface: 'web', - provider_id: 'official-provider', - provider_mode: 'official', - provider_type: 'official', - provider_name: 'official-provider', - entry_page: 'manual_chat_ping', - step: 'manual_chat_ping', duration_ms: 18, success: true, }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'official_provider_enabled', { + + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_connection_test_started', { app_surface: 'web', - provider_name: 'official-provider', - entry: 'settings', + provider_id: 'official-provider', + provider_mode: 'official', + trigger_method: 'button', + trigger_type: 'user_action', }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'provider_config_failed', { + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'provider_connection_test_completed', { app_surface: 'web', - provider_id: 'openai-compatible', - provider_mode: 'custom', - step: 'settings_auto_validate', - error_code: 'validation_failed', - duration_ms: 32, + provider_id: 'official-provider', + provider_mode: 'official', + duration_ms: 18, + success: true, + trigger_method: 'button', + trigger_type: 'user_flow_result', + }) + expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledTimes(2) + }) + + it('marks provider and model selections as explicit user actions', () => { + const analytics = useAnalytics() + + analytics.trackProviderClick('official-provider', 'consciousness') + analytics.trackModelSwitched('none', 'model-b') + + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'provider_card_clicked', { + app_surface: 'web', + provider_id: 'official-provider', + module: 'consciousness', + trigger_method: 'provider_card', + trigger_type: 'user_action', + }) + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'model_switched', { + app_surface: 'web', + from_model: 'none', + to_model: 'model-b', + reason: 'manual', + trigger_method: 'selection', + trigger_type: 'user_action', }) }) @@ -701,6 +552,8 @@ describe('useAnalytics conversation product events', () => { message_length: 24, has_attachment: false, mode: 'text', + trigger_method: 'text_input', + trigger_type: 'user_action', }) expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'quota_limit_reached', { limit_type: 'flux', @@ -755,13 +608,6 @@ describe('useAnalytics conversation product events', () => { preset_type: 'stage_model', source: 'settings', }) - analytics.trackProviderSwitched({ - from_provider: 'openai-compatible', - to_provider: 'official-provider', - from_provider_type: 'custom', - to_provider_type: 'official', - reason: 'manual', - }) analytics.trackSettingsChanged({ setting_name: 'analytics_enabled', previous_value: false, @@ -810,22 +656,14 @@ describe('useAnalytics conversation product events', () => { preset_type: 'stage_model', source: 'settings', }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'provider_switched', { - app_surface: 'web', - from_provider: 'openai-compatible', - to_provider: 'official-provider', - from_provider_type: 'custom', - to_provider_type: 'official', - reason: 'manual', - }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'settings_changed', { + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'settings_changed', { app_surface: 'web', setting_name: 'analytics_enabled', previous_value: false, new_value: true, source: 'settings', }) - expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'support_contacted', { + expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'support_contacted', { app_surface: 'web', channel: 'discord', source: 'settings', diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index 2cd1d3fb5..9b3b008f4 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -22,10 +22,8 @@ export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'session export type ProviderMode = 'official' | 'custom' | 'unknown' export type ChatActivationFailureStage = 'provider_config' | 'model_list' | 'message_send' | 'llm_response' | 'tts' -export type ProviderConfigStep = 'settings_auto_validate' | 'manual_chat_ping' | 'onboarding_validate' export type VoiceType = 'official_default' | 'official_selected' | 'custom_configured' | 'voice_pack' | 'unknown' export type VoiceAnalyticsSource = 'settings' | 'onboarding' | 'chat_auto_tts' | 'manual_preview' -export type OfficialProviderSelectionSource = 'settings' | 'onboarding' | 'default_auto' export type OfficialTtsExposureSource = 'settings' | 'onboarding' | 'post_first_chat' | 'chat_controls' export type FluxBalanceBucket = 'zero' | '1_100' | '101_1000' | '1001_10000' | '10000_plus' | 'unknown' export type FeedbackSource = 'app' | 'discord' | 'qq' | 'github' | 'email' | 'other' @@ -60,13 +58,6 @@ interface ChatRoundCorrelationProperties { turn_index: number } -interface ChatActivationBaseProperties extends ChatRoundCorrelationProperties { - provider_mode: ProviderMode - provider_id: string - model_id: string - source: 'text' | 'voice' -} - interface TtsVoiceBaseProperties { tts_provider_id: string tts_model_id: string @@ -84,10 +75,9 @@ interface VoiceInputBaseProperties { duration_ms?: number } -interface ProviderConfigBaseProperties { +interface ProviderConnectionTestProperties { provider_id: string provider_mode: ProviderMode - step: ProviderConfigStep } interface FeedbackBaseProperties { @@ -145,6 +135,9 @@ export function useAnalytics() { captureAnalyticsEvent('provider_card_clicked', { provider_id: providerId, module, + app_surface: getConversationAnalyticsSurface(), + trigger_method: 'provider_card', + trigger_type: 'user_action', }) } @@ -330,12 +323,13 @@ export function useAnalytics() { function trackModelSwitched(fromModel: string, toModel: string, reason: 'manual' | 'auto' = 'manual') { if (!canCapture()) return - captureAnalyticsEvent('model_switched', { from_model: fromModel, to_model: toModel, reason }) - captureAnalyticsEvent('model_changed', { + captureAnalyticsEvent('model_switched', { from_model: fromModel, to_model: toModel, reason, app_surface: getConversationAnalyticsSurface(), + trigger_method: reason === 'manual' ? 'selection' : 'automatic', + trigger_type: reason === 'manual' ? 'user_action' : 'user_flow_result', }) } @@ -350,37 +344,6 @@ export function useAnalytics() { captureAnalyticsEvent('chat_session_started', { model_id: modelId, ...(sessionIndex != null && { session_index: sessionIndex }) }) } - // ─── LLM round events (client-known fields only) ────────────────────── - // The server owns HTTP status, token usage, and billing state. It records - // these request-level facts in operational telemetry, not product analytics. - // These client events supply latency data the server cannot observe. - - function trackMessageSendStarted(properties: ChatRoundCorrelationProperties & { source: 'text' | 'voice', model?: string }) { - if (!canCapture()) - return - captureAnalyticsEvent('message_send_started', properties) - } - - function trackLlmRequestStarted(properties: ChatRoundCorrelationProperties & { model: string, provider: string, has_voice: boolean }) { - if (!canCapture()) - return - 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 - 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 - captureAnalyticsEvent('assistant_response_rendered', properties) - } - /** Cost-fact event for one custom-provider generation; content is intentionally excluded. */ function trackAiGeneration(properties: { conversation_id: string @@ -455,53 +418,6 @@ export function useAnalytics() { }) } - // ─── Chat activation events ────────────────────────────────────────── - - function trackChatActivationStarted(properties: ChatActivationBaseProperties) { - if (!canCapture()) - return - captureAnalyticsEvent('chat_activation_started', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackChatActivationSucceeded(properties: ChatActivationBaseProperties & { time_to_first_message_ms?: number }) { - if (!canCapture()) - return - captureAnalyticsEvent('chat_activation_succeeded', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackChatActivationFailed(properties: ChatActivationBaseProperties & { - error_code: string - failure_stage: ChatActivationFailureStage - }) { - if (!canCapture()) - return - captureAnalyticsEvent('chat_activation_failed', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackOfficialProviderSelected(properties: { - provider_id: string - provider_mode: ProviderMode - source: OfficialProviderSelectionSource - auto_selected: boolean - model_id?: string - }) { - if (!canCapture()) - return - captureAnalyticsEvent('official_provider_selected', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - function trackMessageSent(properties: ConversationBaseProperties & { round_id: string turn_index: number @@ -516,83 +432,34 @@ export function useAnalytics() { captureAnalyticsEvent('message_sent', { ...properties, app_surface: getConversationAnalyticsSurface(), + trigger_method: properties.mode === 'voice' ? 'voice' : 'text_input', + trigger_type: 'user_action', }) } - function trackSecondTurnStarted(properties: ChatActivationBaseProperties) { + function trackProviderConnectionTestStarted(properties: ProviderConnectionTestProperties) { if (!canCapture()) return - captureAnalyticsEvent('second_turn_started', { + captureAnalyticsEvent('provider_connection_test_started', { ...properties, app_surface: getConversationAnalyticsSurface(), + trigger_method: 'button', + trigger_type: 'user_action', }) } - function trackProviderConfigStarted(properties: ProviderConfigBaseProperties) { - if (!canCapture()) - return - captureAnalyticsEvent('provider_config_started', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackProviderConfigSucceeded(properties: ProviderConfigBaseProperties & { duration_ms: number }) { - if (!canCapture()) - return - captureAnalyticsEvent('provider_config_succeeded', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - trackProviderConfigCompleted({ - ...properties, - success: true, - }) - if (properties.provider_mode === 'official') { - trackOfficialProviderEnabled({ - provider_name: properties.provider_id, - entry: properties.step === 'onboarding_validate' ? 'onboarding' : 'settings', - }) - } - } - - function trackProviderConfigFailed(properties: ProviderConfigBaseProperties & { - error_code: string - duration_ms: number - }) { - if (!canCapture()) - return - captureAnalyticsEvent('provider_config_failed', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackProviderConfigCompleted(properties: ProviderConfigBaseProperties & { + function trackProviderConnectionTestCompleted(properties: ProviderConnectionTestProperties & { duration_ms: number success: boolean error_code?: string }) { if (!canCapture()) return - captureAnalyticsEvent('provider_config_completed', { - ...properties, - provider_type: properties.provider_mode, - provider_name: properties.provider_id, - entry_page: properties.step, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackOfficialProviderEnabled(properties: { - provider_name: string - entry: 'onboarding' | 'settings' | 'chat' - }) { - if (!canCapture()) - return - captureAnalyticsEvent('official_provider_enabled', { + captureAnalyticsEvent('provider_connection_test_completed', { ...properties, app_surface: getConversationAnalyticsSurface(), + trigger_method: 'button', + trigger_type: 'user_flow_result', }) } @@ -708,12 +575,6 @@ export function useAnalytics() { // ─── STT events ────────────────────────────────────────────────────── - function trackSttStarted(provider: string) { - if (!canCapture()) - return - captureAnalyticsEvent('stt_started', { provider }) - } - function trackSttSucceeded(properties: { provider: string, latency_ms: number, char_count: number, stream: boolean }) { if (!canCapture()) return @@ -732,19 +593,8 @@ export function useAnalytics() { captureAnalyticsEvent('voice_input_started', { ...properties, app_surface: getConversationAnalyticsSurface(), - }) - captureAnalyticsEvent('voice_input_used', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackMicrophonePermissionRequested(properties: VoiceInputBaseProperties) { - if (!canCapture()) - return - captureAnalyticsEvent('microphone_permission_requested', { - ...properties, - app_surface: getConversationAnalyticsSurface(), + trigger_method: 'voice', + trigger_type: 'user_action', }) } @@ -754,15 +604,8 @@ export function useAnalytics() { captureAnalyticsEvent('microphone_permission_denied', { ...properties, app_surface: getConversationAnalyticsSurface(), - }) - } - - function trackAudioDeviceUnavailable(properties: VoiceInputBaseProperties & { error_code?: 'device_unavailable' | string }) { - if (!canCapture()) - return - captureAnalyticsEvent('audio_device_unavailable', { - ...properties, - app_surface: getConversationAnalyticsSurface(), + trigger_method: 'voice', + trigger_type: 'user_flow_result', }) } @@ -772,6 +615,8 @@ export function useAnalytics() { captureAnalyticsEvent('voice_input_cancelled', { ...properties, app_surface: getConversationAnalyticsSurface(), + trigger_method: 'voice', + trigger_type: 'user_flow_result', }) } @@ -813,35 +658,19 @@ export function useAnalytics() { captureAnalyticsEvent('ptt_released', { hold_ms: holdMs }) } - // ─── TTS events (forwarded from speech bus by use-speech-pipeline-analytics) ─ + // ─── TTS selection events ──────────────────────────────────────────── // Selection events use catalog `voice_id` values for adoption analysis. // Custom voices must pass `voice_id = custom` from the callsite when the // raw provider value is user supplied. - function trackTtsIntentStarted(properties: { intent_id: string, turn_id?: string }) { - if (!canCapture()) - return - captureAnalyticsEvent('tts_intent_started', properties) - } - - function trackTtsIntentEnded(properties: { intent_id: string, turn_id?: string, duration_ms: number }) { - if (!canCapture()) - return - captureAnalyticsEvent('tts_intent_ended', properties) - } - - function trackTtsIntentCancelled(properties: { intent_id: string, turn_id?: string, reason?: string }) { - if (!canCapture()) - return - captureAnalyticsEvent('tts_intent_cancelled', properties) - } - function trackTtsProviderSelected(properties: TtsVoiceBaseProperties) { if (!canCapture()) return captureAnalyticsEvent('tts_provider_selected', { ...properties, app_surface: getConversationAnalyticsSurface(), + trigger_method: properties.source === 'chat_auto_tts' ? 'automatic' : 'selection', + trigger_type: properties.source === 'chat_auto_tts' ? 'user_flow_result' : 'user_action', }) } @@ -948,21 +777,6 @@ export function useAnalytics() { }) } - function trackProviderSwitched(properties: { - from_provider?: string - to_provider: string - from_provider_type?: ProviderMode - to_provider_type: ProviderMode - reason: 'manual' | 'auto' - }) { - if (!canCapture()) - return - captureAnalyticsEvent('provider_switched', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - function trackSettingsChanged(properties: { setting_name: string previous_value?: string | number | boolean @@ -990,18 +804,6 @@ export function useAnalytics() { }) } - function trackOfficialTtsAutoEnabled(properties: Omit & { - source: Extract - enabled: boolean - }) { - if (!canCapture()) - return - captureAnalyticsEvent('official_tts_auto_enabled', { - ...properties, - app_surface: getConversationAnalyticsSurface(), - }) - } - // ─── Autonomous LLM path (artistry-autonomous bypasses chat orchestrator) ─ function trackAutonomousGenerateText(properties: { model: string, reason?: string }) { @@ -1245,24 +1047,12 @@ export function useAnalytics() { trackModelSwitched, trackChatSessionStarted, - trackMessageSendStarted, - trackLlmRequestStarted, - trackLlmFirstToken, - trackAssistantResponseRendered, trackAiGeneration, trackMessageRound, trackMessageRoundFailed, trackMessageSent, - trackChatActivationStarted, - trackChatActivationSucceeded, - trackChatActivationFailed, - trackOfficialProviderSelected, - trackSecondTurnStarted, - trackProviderConfigStarted, - trackProviderConfigSucceeded, - trackProviderConfigFailed, - trackProviderConfigCompleted, - trackOfficialProviderEnabled, + trackProviderConnectionTestStarted, + trackProviderConnectionTestCompleted, trackTtsStopClicked, trackSpeechMuteToggled, trackChatSessionSelected, @@ -1274,13 +1064,10 @@ export function useAnalytics() { trackConversationShared, trackConversationDeleted, - trackSttStarted, trackSttSucceeded, trackSttFailed, trackVoiceInputStarted, - trackMicrophonePermissionRequested, trackMicrophonePermissionDenied, - trackAudioDeviceUnavailable, trackVoiceInputCancelled, trackBugReportSubmitted, trackFeedbackSubmitted, @@ -1288,22 +1075,17 @@ export function useAnalytics() { trackPttPressed, trackPttReleased, - trackTtsIntentStarted, - trackTtsIntentEnded, - trackTtsIntentCancelled, trackTtsProviderSelected, trackVoiceSelected, trackVoicePreviewPlayed, trackVoicePackBound, trackAttachmentUploaded, trackPresetUsed, - trackProviderSwitched, trackSettingsChanged, trackSupportContacted, trackOfficialTtsExposed, trackOfficialTtsPreviewStarted, trackOfficialTtsPreviewSucceeded, - trackOfficialTtsAutoEnabled, trackAutonomousGenerateText, diff --git a/packages/stage-ui/src/composables/use-provider-validation.ts b/packages/stage-ui/src/composables/use-provider-validation.ts index 9cd5c8a85..f283d98ed 100644 --- a/packages/stage-ui/src/composables/use-provider-validation.ts +++ b/packages/stage-ui/src/composables/use-provider-validation.ts @@ -1,6 +1,6 @@ import type { RemovableRef } from '@vueuse/core' -import type { ProviderConfigStep, ProviderMode } from './use-analytics' +import type { ProviderMode } from './use-analytics' import { errorMessageFrom } from '@moeru/std' import { useDebounceFn } from '@vueuse/core' @@ -32,9 +32,8 @@ export function useProviderValidation(providerId: string) { const providersStore = useProviderStore() const providerStore = useProviderConfigStore() const { - trackProviderConfigFailed, - trackProviderConfigStarted, - trackProviderConfigSucceeded, + trackProviderConnectionTestCompleted, + trackProviderConnectionTestStarted, } = useAnalytics() const { configs: providers } = storeToRefs(providerStore) as { configs: RemovableRef> } @@ -88,14 +87,10 @@ export function useProviderValidation(providerId: string) { const manualTestPassed = ref(false) const manualTestMessage = ref('') - /** - * Builds the stable provider analytics fields shared by validation events. - */ - function providerConfigAnalyticsBase(step: ProviderConfigStep) { + function providerConnectionTestAnalyticsBase() { return { provider_id: providerId, provider_mode: providerModeForAnalytics(providerId), - step, } } @@ -106,7 +101,6 @@ export function useProviderValidation(providerId: string) { isValidating.value++ validationMessage.value = '' const startValidationTimestamp = performance.now() - trackProviderConfigStarted(providerConfigAnalyticsBase('settings_auto_validate')) let finalValidationMessage = '' try { @@ -125,11 +119,6 @@ export function useProviderValidation(providerId: string) { if (!isValid.value) { finalValidationMessage = validationResult.reason - trackProviderConfigFailed({ - ...providerConfigAnalyticsBase('settings_auto_validate'), - error_code: 'validation_failed', - duration_ms: Math.round(performance.now() - startValidationTimestamp), - }) } // When a provider validates successfully on its settings page, @@ -138,10 +127,6 @@ export function useProviderValidation(providerId: string) { // need an API key, yet should be selectable after successful validation. if (isValid.value) { providerStore.markProviderAdded(providerId) - trackProviderConfigSucceeded({ - ...providerConfigAnalyticsBase('settings_auto_validate'), - duration_ms: Math.round(performance.now() - startValidationTimestamp), - }) } } catch (error) { @@ -149,11 +134,6 @@ export function useProviderValidation(providerId: string) { finalValidationMessage = t('settings.dialogs.onboarding.validationError', { error: errorMessageFrom(error) ?? 'Generic error (993b5ad7)', }) - trackProviderConfigFailed({ - ...providerConfigAnalyticsBase('settings_auto_validate'), - error_code: 'provider_error', - duration_ms: Math.round(performance.now() - startValidationTimestamp), - }) } finally { setTimeout(() => { @@ -170,7 +150,7 @@ export function useProviderValidation(providerId: string) { isManualTesting.value = true manualTestMessage.value = '' const startedAt = performance.now() - trackProviderConfigStarted(providerConfigAnalyticsBase('manual_chat_ping')) + trackProviderConnectionTestStarted(providerConnectionTestAnalyticsBase()) try { const config = { ...credentials.value } @@ -184,27 +164,30 @@ export function useProviderValidation(providerId: string) { }) manualTestPassed.value = result.valid if (result.valid) { - trackProviderConfigSucceeded({ - ...providerConfigAnalyticsBase('manual_chat_ping'), + trackProviderConnectionTestCompleted({ + ...providerConnectionTestAnalyticsBase(), duration_ms: Math.round(performance.now() - startedAt), + success: true, }) } else { manualTestMessage.value = result.reason - trackProviderConfigFailed({ - ...providerConfigAnalyticsBase('manual_chat_ping'), + trackProviderConnectionTestCompleted({ + ...providerConnectionTestAnalyticsBase(), error_code: 'validation_failed', duration_ms: Math.round(performance.now() - startedAt), + success: false, }) } } catch (error) { manualTestPassed.value = false manualTestMessage.value = errorMessageFrom(error) ?? 'Generic error (e56ae24f)' - trackProviderConfigFailed({ - ...providerConfigAnalyticsBase('manual_chat_ping'), + trackProviderConnectionTestCompleted({ + ...providerConnectionTestAnalyticsBase(), error_code: 'provider_error', duration_ms: Math.round(performance.now() - startedAt), + success: false, }) } finally { diff --git a/packages/stage-ui/src/composables/use-speech-pipeline-analytics.ts b/packages/stage-ui/src/composables/use-speech-pipeline-analytics.ts deleted file mode 100644 index d6280768d..000000000 --- a/packages/stage-ui/src/composables/use-speech-pipeline-analytics.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { errorMessageFrom } from '@moeru/std' -import { onScopeDispose } from 'vue' - -import { - getSpeechBusContext, - speechIntentCancelEvent, - speechIntentEndEvent, - speechIntentStartEvent, -} from '../services/speech/bus' -import { useAnalytics } from './use-analytics' - -/** - * Forwards `speechIntent*` lifecycle events from the cross-window speech - * bus into PostHog `tts_intent_started/ended/cancelled`. - * - * Use when: - * - Each app shell boots. Mount exactly once per window — the bus is a - * module singleton, so duplicate mounts double-count. - * - * Expects: - * - Called inside a Vue effect scope so `onScopeDispose` can clean up. - * - * Per-token events (`literal` / `special` / `flush`) are deliberately - * skipped: hot path, no analytical value, would torch PostHog quota. - */ -export function useSpeechPipelineAnalytics() { - const { trackTtsIntentStarted, trackTtsIntentEnded, trackTtsIntentCancelled } = useAnalytics() - const ctx = getSpeechBusContext() - - // Intents that never end (process killed mid-response) leak entries - // until the window unloads — fine for a per-session in-memory map. - const intentStartedAt = new Map() - - const disposers: Array<() => void> = [] - - function safeForward(label: string, fn: () => void) { - try { - fn() - } - catch (err) { - console.warn(`[speech-pipeline-analytics] ${label} forward failed:`, errorMessageFrom(err)) - } - } - - disposers.push(ctx.on(speechIntentStartEvent, (evt) => { - const payload = evt?.body - if (!payload?.intentId) - return - intentStartedAt.set(payload.intentId, Date.now()) - safeForward('intent-start', () => { - trackTtsIntentStarted({ intent_id: payload.intentId, turn_id: payload.turnId }) - }) - })) - - disposers.push(ctx.on(speechIntentEndEvent, (evt) => { - const payload = evt?.body - if (!payload?.intentId) - return - const startedAt = intentStartedAt.get(payload.intentId) - intentStartedAt.delete(payload.intentId) - safeForward('intent-end', () => { - trackTtsIntentEnded({ - intent_id: payload.intentId, - turn_id: payload.turnId, - duration_ms: startedAt ? Date.now() - startedAt : 0, - }) - }) - })) - - disposers.push(ctx.on(speechIntentCancelEvent, (evt) => { - const payload = evt?.body - if (!payload?.intentId) - return - intentStartedAt.delete(payload.intentId) - safeForward('intent-cancel', () => { - trackTtsIntentCancelled({ - intent_id: payload.intentId, - turn_id: payload.turnId, - reason: payload.reason, - }) - }) - })) - - onScopeDispose(() => { - for (const dispose of disposers) { - try { - dispose() - } - catch { /* swallow — disposers must not throw */ } - } - disposers.length = 0 - intentStartedAt.clear() - }) -} diff --git a/packages/stage-ui/src/libs/analytics/events/app/events/lifecycle.ts b/packages/stage-ui/src/libs/analytics/events/app/events/lifecycle.ts index c46f14e87..1765954a8 100644 --- a/packages/stage-ui/src/libs/analytics/events/app/events/lifecycle.ts +++ b/packages/stage-ui/src/libs/analytics/events/app/events/lifecycle.ts @@ -15,36 +15,10 @@ export const analyticsSettingChangedEvent = defineEvent<{ export const firstMessageSentEvent = defineEvent<{ time_to_first_message_ms: number | null + trigger_method: 'message_send' + trigger_type: 'user_action' }>('first_message_sent') -export const firstModelSelectedEvent = defineEvent<{ - model_id: string - provider: string -}>('first_model_selected') - -export const providerSwitchedEvent = defineEvent<{ - from_provider: string - to_provider: string - from_provider_type: 'official' | 'custom' | 'unknown' - to_provider_type: 'official' | 'custom' | 'unknown' - reason: 'manual' - app_surface: 'web' | 'desktop' | 'mobile' -}>('provider_switched') - -export const modelSwitchedEvent = defineEvent<{ - from_model: string - to_model: string - reason: 'manual' -}>('model_switched') - -export const modelChangedEvent = defineEvent<{ - from_model: string - to_model: string - provider: string - reason: 'manual' - app_surface: 'web' | 'desktop' | 'mobile' -}>('model_changed') - export const characterSwitchedEvent = defineEvent<{ from_character_id: string to_character_id: string diff --git a/packages/stage-ui/src/libs/analytics/events/chat/events/activation.ts b/packages/stage-ui/src/libs/analytics/events/chat/events/activation.ts deleted file mode 100644 index cbd248882..000000000 --- a/packages/stage-ui/src/libs/analytics/events/chat/events/activation.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineEvent } from '../../../utils/dsl' - -interface ChatActivationProperties { - conversation_id: string - round_id: string - turn_index: number - provider_mode: 'official' | 'custom' | 'unknown' - provider_id: string - model_id: string - source: 'text' | 'voice' -} - -export const chatActivationStartedEvent = defineEvent('chat_activation_started') - -export const chatActivationSucceededEvent = defineEvent('chat_activation_succeeded') - -export const chatActivationFailedEvent = defineEvent('chat_activation_failed') diff --git a/packages/stage-ui/src/libs/analytics/events/chat/events/index.ts b/packages/stage-ui/src/libs/analytics/events/chat/events/index.ts index deb9400e7..1ae5b4a0f 100644 --- a/packages/stage-ui/src/libs/analytics/events/chat/events/index.ts +++ b/packages/stage-ui/src/libs/analytics/events/chat/events/index.ts @@ -1,4 +1,3 @@ -export * from './activation' export * from './generation' export * from './message' export * from './round' diff --git a/packages/stage-ui/src/libs/analytics/events/chat/events/message.ts b/packages/stage-ui/src/libs/analytics/events/chat/events/message.ts index 38d8a6853..8b073fedd 100644 --- a/packages/stage-ui/src/libs/analytics/events/chat/events/message.ts +++ b/packages/stage-ui/src/libs/analytics/events/chat/events/message.ts @@ -12,14 +12,6 @@ export const messageSentEvent = defineEvent<{ message_length: number has_attachment: boolean mode: 'text' | 'voice' + trigger_method: 'text_input' | 'voice' + trigger_type: 'user_action' }>('message_sent') - -export const secondTurnStartedEvent = defineEvent<{ - conversation_id: string - provider_mode: 'official' | 'custom' | 'unknown' - provider_id: string - model_id: string - round_id: string - source: 'text' | 'voice' - turn_index: number -}>('second_turn_started') diff --git a/packages/stage-ui/src/libs/analytics/events/chat/events/round.ts b/packages/stage-ui/src/libs/analytics/events/chat/events/round.ts index 1dc742016..ecc1cb03e 100644 --- a/packages/stage-ui/src/libs/analytics/events/chat/events/round.ts +++ b/packages/stage-ui/src/libs/analytics/events/chat/events/round.ts @@ -6,27 +6,6 @@ interface ChatRoundProperties { turn_index: number } -export const messageSendStartedEvent = defineEvent('message_send_started') - -export const llmRequestStartedEvent = defineEvent('llm_request_started') - -export const llmFirstTokenEvent = defineEvent('llm_first_token') - -export const assistantResponseRenderedEvent = defineEvent('assistant_response_rendered') - export const messageRoundEvent = defineEvent('message_round') export const messageRoundFailedEvent = defineEvent('message_round_failed') diff --git a/packages/stage-ui/src/libs/analytics/events/chat/runtime.test.ts b/packages/stage-ui/src/libs/analytics/events/chat/runtime.test.ts index 6b92ad8df..4be884412 100644 --- a/packages/stage-ui/src/libs/analytics/events/chat/runtime.test.ts +++ b/packages/stage-ui/src/libs/analytics/events/chat/runtime.test.ts @@ -2,7 +2,7 @@ import type { AnalyticsRecorder } from '../../index' import { describe, expect, it, vi } from 'vitest' -import { aiGenerationEvent, messageSentEvent, secondTurnStartedEvent } from './events' +import { aiGenerationEvent, messageSentEvent } from './events' import { createChatAnalyticsHooks } from './runtime' function createRecorder(): AnalyticsRecorder { @@ -43,16 +43,37 @@ describe('createChatAnalyticsHooks', () => { message_length: 5, has_attachment: false, mode: 'voice', + trigger_method: 'voice', + trigger_type: 'user_action', }) - expect(analytics.emit).toHaveBeenCalledWith(secondTurnStartedEvent, { - conversation_id: 'session-1', - provider_mode: 'official', - provider_id: 'official-provider-chat', - model_id: 'selected-model', - round_id: 'round-1', - source: 'voice', - turn_index: 2, + expect(analytics.emit).toHaveBeenCalledTimes(1) + }) + + it('does not expose intermediate chat lifecycle hooks as product events', () => { + const hooks = createChatAnalyticsHooks({ + analytics: createRecorder(), + getSessionMessages: () => [], }) + + expect(hooks).not.toHaveProperty('onMessageSendStarted') + expect(hooks).not.toHaveProperty('onLlmRequestStarted') + expect(hooks).not.toHaveProperty('onLlmFirstToken') + expect(hooks).not.toHaveProperty('onAssistantResponseRendered') + expect(hooks).not.toHaveProperty('onChatActivationStarted') + expect(hooks).not.toHaveProperty('onChatActivationSucceeded') + expect(hooks).not.toHaveProperty('onChatActivationFailed') + }) + + it('keeps the first-message activation signal', () => { + const analytics = createRecorder() + const hooks = createChatAnalyticsHooks({ + analytics, + getSessionMessages: () => [], + }) + + hooks.onTrackFirstMessage?.() + + expect(analytics.recordFirstMessage).toHaveBeenCalledOnce() }) it('records generation usage only for custom providers', () => { diff --git a/packages/stage-ui/src/libs/analytics/events/chat/runtime.ts b/packages/stage-ui/src/libs/analytics/events/chat/runtime.ts index 623588ed8..05e56d503 100644 --- a/packages/stage-ui/src/libs/analytics/events/chat/runtime.ts +++ b/packages/stage-ui/src/libs/analytics/events/chat/runtime.ts @@ -6,32 +6,17 @@ import type { AnalyticsRecorder } from '../../index' import { getAnalytics } from '../../index' import { aiGenerationEvent, - assistantResponseRenderedEvent, - chatActivationFailedEvent, - chatActivationStartedEvent, - chatActivationSucceededEvent, - llmFirstTokenEvent, - llmRequestStartedEvent, messageRoundEvent, messageRoundFailedEvent, - messageSendStartedEvent, messageSentEvent, - secondTurnStartedEvent, } from './events' import { getProviderMode } from './types' type ChatAnalyticsCallbacks = Pick< ChatOrchestratorRuntimeDeps, - | 'onAssistantResponseRendered' - | 'onChatActivationFailed' - | 'onChatActivationStarted' - | 'onChatActivationSucceeded' - | 'onLlmFirstToken' | 'onLlmGeneration' - | 'onLlmRequestStarted' | 'onMessageRound' | 'onMessageRoundFailed' - | 'onMessageSendStarted' | 'onTrackFirstMessage' | 'onUserMessageAppended' > @@ -55,43 +40,6 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption return { onTrackFirstMessage: () => analytics.recordFirstMessage(), - onMessageSendStarted: ({ conversationId, roundId, turnIndex, source, model }) => { - analytics.emit(messageSendStartedEvent, { - conversation_id: conversationId, - round_id: roundId, - turn_index: turnIndex, - source, - model, - }) - }, - onLlmRequestStarted: ({ conversationId, roundId, turnIndex, model, provider, hasVoice }) => { - analytics.emit(llmRequestStartedEvent, { - conversation_id: conversationId, - round_id: roundId, - turn_index: turnIndex, - model, - provider, - has_voice: hasVoice, - }) - }, - onLlmFirstToken: ({ conversationId, roundId, turnIndex, model, ttfbMs }) => { - analytics.emit(llmFirstTokenEvent, { - conversation_id: conversationId, - round_id: roundId, - turn_index: turnIndex, - model, - ttfb_ms: ttfbMs, - }) - }, - onAssistantResponseRendered: ({ conversationId, roundId, turnIndex, model, latencyMs }) => { - analytics.emit(assistantResponseRenderedEvent, { - conversation_id: conversationId, - round_id: roundId, - turn_index: turnIndex, - model, - latency_ms: latencyMs, - }) - }, onLlmGeneration: ({ conversationId, roundId, model, provider, inputTokens, outputTokens, totalTokens, usageSource }) => { const providerType = getProviderMode(provider) if (providerType !== 'custom') @@ -121,6 +69,8 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption output_tokens: outputTokens, total_tokens: totalTokens, usage_source: usageSource, + trigger_method: hasVoice ? 'voice' : 'text_input', + trigger_type: 'user_flow_result', }) }, onMessageRoundFailed: ({ conversationId, roundId, turnIndex, model, provider, errorCode, failureStage, source }) => { @@ -133,42 +83,8 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption source, error_code: errorCode, failure_stage: failureStage, - }) - }, - onChatActivationStarted: ({ conversationId, roundId, turnIndex, model, provider, source }) => { - analytics.emit(chatActivationStartedEvent, { - conversation_id: conversationId, - provider_mode: getProviderMode(provider), - provider_id: provider || 'unknown', - model_id: model || 'unknown', - round_id: roundId, - source, - turn_index: turnIndex, - }) - }, - onChatActivationSucceeded: ({ conversationId, roundId, turnIndex, model, provider, durationMs, source }) => { - analytics.emit(chatActivationSucceededEvent, { - conversation_id: conversationId, - provider_mode: getProviderMode(provider), - provider_id: provider || 'unknown', - model_id: model || 'unknown', - round_id: roundId, - time_to_first_message_ms: durationMs, - source, - turn_index: turnIndex, - }) - }, - onChatActivationFailed: ({ conversationId, roundId, turnIndex, model, provider, errorCode, failureStage, source }) => { - analytics.emit(chatActivationFailedEvent, { - conversation_id: conversationId, - provider_mode: getProviderMode(provider), - provider_id: provider || 'unknown', - model_id: model || 'unknown', - round_id: roundId, - error_code: errorCode, - failure_stage: failureStage, - source, - turn_index: turnIndex, + trigger_method: source === 'voice' ? 'voice' : 'text_input', + trigger_type: 'user_flow_result', }) }, onUserMessageAppended: ({ sessionId, message, messageText, source, model, provider, roundId, turnIndex }) => { @@ -185,18 +101,8 @@ export function createChatAnalyticsHooks(options: CreateChatAnalyticsHooksOption message_length: messageText.length, has_attachment: false, mode: source, - }) - if (turnIndex !== 2) - return - - analytics.emit(secondTurnStartedEvent, { - conversation_id: sessionId, - provider_mode: providerType, - provider_id: provider || 'unknown', - model_id: model || 'unknown', - round_id: roundId, - source, - turn_index: turnIndex, + trigger_method: source === 'voice' ? 'voice' : 'text_input', + trigger_type: 'user_action', }) }, } diff --git a/packages/stage-ui/src/libs/analytics/index.ts b/packages/stage-ui/src/libs/analytics/index.ts index c94eae054..cabc46059 100644 --- a/packages/stage-ui/src/libs/analytics/index.ts +++ b/packages/stage-ui/src/libs/analytics/index.ts @@ -7,7 +7,6 @@ import { watch } from 'vue' import { useBuildInfo } from '../../composables/use-build-info' import { useAuthStore } from '../../stores/auth' import { useAiriCardStore } from '../../stores/modules/airi-card' -import { useConsciousnessStore } from '../../stores/modules/consciousness' import { useSettingsAnalytics } from '../../stores/settings/analytics' import { captureAnalyticsEvent, @@ -25,12 +24,7 @@ import { appLoadedEvent, characterSwitchedEvent, firstMessageSentEvent, - firstModelSelectedEvent, - modelChangedEvent, - modelSwitchedEvent, - providerSwitchedEvent, } from './events/app' -import { getProviderMode } from './events/chat/types' export * from './client' export * from './events' @@ -66,7 +60,6 @@ function analyticsSurface(): 'web' | 'desktop' | 'mobile' { class Analytics implements AnalyticsRecorder { private appStartTime: number | null = null private firstMessageRecorded = false - private firstModelSelectedRecorded = false private initialized = false initialize(): void { @@ -136,57 +129,6 @@ class Analytics implements AnalyticsRecorder { identifyAnalyticsUser(authStore.user.id) }) - const consciousness = useConsciousnessStore() - watch( - () => ({ provider: consciousness.activeProvider, model: consciousness.activeModel }), - (next, previous) => { - if (!next.provider || !next.model) - return - - if (!previous) { - if (!this.firstModelSelectedRecorded && this.emit(firstModelSelectedEvent, { model_id: next.model, provider: next.provider })) - this.firstModelSelectedRecorded = true - return - } - - if (previous.provider === next.provider && previous.model === next.model) - return - - if (!this.firstModelSelectedRecorded) { - if (this.emit(firstModelSelectedEvent, { model_id: next.model, provider: next.provider })) - this.firstModelSelectedRecorded = true - return - } - - if (previous.provider && previous.provider !== next.provider) { - this.emit(providerSwitchedEvent, { - from_provider: previous.provider, - to_provider: next.provider, - from_provider_type: getProviderMode(previous.provider), - to_provider_type: getProviderMode(next.provider), - reason: 'manual', - app_surface: analyticsSurface(), - }) - } - - if (previous.model) { - this.emit(modelSwitchedEvent, { - from_model: previous.model, - to_model: next.model, - reason: 'manual', - }) - this.emit(modelChangedEvent, { - from_model: previous.model, - to_model: next.model, - provider: next.provider, - reason: 'manual', - app_surface: analyticsSurface(), - }) - } - }, - { immediate: true }, - ) - const cardStore = useAiriCardStore() watch(() => cardStore.activeCardId, (next, previous) => { if (!next || !previous || previous === next) @@ -214,6 +156,8 @@ class Analytics implements AnalyticsRecorder { : Date.now() - this.appStartTime const captured = this.emit(firstMessageSentEvent, { time_to_first_message_ms: timeToFirstMessageMs, + trigger_method: 'message_send', + trigger_type: 'user_action', }) if (captured) this.firstMessageRecorded = true diff --git a/packages/stage-ui/src/stores/chat.contract.test.ts b/packages/stage-ui/src/stores/chat.contract.test.ts index 577036cb9..20e15de59 100644 --- a/packages/stage-ui/src/stores/chat.contract.test.ts +++ b/packages/stage-ui/src/stores/chat.contract.test.ts @@ -47,19 +47,10 @@ const llmStreamMock = vi.fn() const trackFirstMessageMock = vi.fn() const chatAnalyticsMocks = vi.hoisted(() => ({ trackAiGeneration: vi.fn(), - trackAssistantResponseRendered: vi.fn(), - trackChatActivationFailed: vi.fn(), - trackChatActivationStarted: vi.fn(), - trackChatActivationSucceeded: vi.fn(), - trackLlmFirstToken: vi.fn(), - trackLlmRequestStarted: vi.fn(), trackMessageRound: vi.fn(), trackMessageRoundFailed: vi.fn(), - trackMessageSendStarted: vi.fn(), trackMessageSent: vi.fn(), - trackSecondTurnStarted: vi.fn(), })) -const trackSecondTurnStartedMock = chatAnalyticsMocks.trackSecondTurnStarted const redundantChatAnalyticsMocks = vi.hoisted(() => ({ trackAssistantResponseCompleted: vi.fn(), trackChatFailed: vi.fn(), @@ -94,25 +85,6 @@ vi.mock('pinia', async () => { vi.mock('../composables', () => ({ getConversationAnalyticsSurface: () => 'web', - useAnalytics: () => ({ - trackFirstMessage: trackFirstMessageMock, - trackChatFailed: redundantChatAnalyticsMocks.trackChatFailed, - trackChatStarted: redundantChatAnalyticsMocks.trackChatStarted, - trackMessageSendStarted: chatAnalyticsMocks.trackMessageSendStarted, - trackMessageSent: chatAnalyticsMocks.trackMessageSent, - trackLlmRequestStarted: chatAnalyticsMocks.trackLlmRequestStarted, - trackLlmFirstToken: chatAnalyticsMocks.trackLlmFirstToken, - trackAiGeneration: chatAnalyticsMocks.trackAiGeneration, - trackAssistantResponseRendered: chatAnalyticsMocks.trackAssistantResponseRendered, - trackAssistantResponseCompleted: redundantChatAnalyticsMocks.trackAssistantResponseCompleted, - trackMessageRound: chatAnalyticsMocks.trackMessageRound, - trackMessageRoundFailed: chatAnalyticsMocks.trackMessageRoundFailed, - trackFeatureUsed: redundantChatAnalyticsMocks.trackFeatureUsed, - trackChatActivationStarted: chatAnalyticsMocks.trackChatActivationStarted, - trackChatActivationSucceeded: chatAnalyticsMocks.trackChatActivationSucceeded, - trackChatActivationFailed: chatAnalyticsMocks.trackChatActivationFailed, - trackSecondTurnStarted: trackSecondTurnStartedMock, - }), })) vi.mock('../libs/analytics', () => ({ @@ -122,39 +94,15 @@ vi.mock('../libs/analytics', () => ({ case '$ai_generation': chatAnalyticsMocks.trackAiGeneration(properties) break - case 'assistant_response_rendered': - chatAnalyticsMocks.trackAssistantResponseRendered(properties) - break - case 'chat_activation_failed': - chatAnalyticsMocks.trackChatActivationFailed(properties) - break - case 'chat_activation_started': - chatAnalyticsMocks.trackChatActivationStarted(properties) - break - case 'chat_activation_succeeded': - chatAnalyticsMocks.trackChatActivationSucceeded(properties) - break - case 'llm_first_token': - chatAnalyticsMocks.trackLlmFirstToken(properties) - break - case 'llm_request_started': - chatAnalyticsMocks.trackLlmRequestStarted(properties) - break case 'message_round': chatAnalyticsMocks.trackMessageRound(properties) break case 'message_round_failed': chatAnalyticsMocks.trackMessageRoundFailed(properties) break - case 'message_send_started': - chatAnalyticsMocks.trackMessageSendStarted(properties) - break case 'message_sent': chatAnalyticsMocks.trackMessageSent(properties) break - case 'second_turn_started': - chatAnalyticsMocks.trackSecondTurnStarted(properties) - break default: return false } @@ -391,7 +339,7 @@ describe('chat store contract', () => { expect(sessionMessages['session-2']).toBeUndefined() }) - it('forwards one correlation identity across every PostHog chat milestone', async () => { + it('forwards one correlation identity across the action and result events', async () => { llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => { await options.onStreamEvent({ type: 'text-delta', text: 'ok' }) await options.onStreamEvent({ type: 'finish', finishReason: 'stop' }) @@ -415,13 +363,7 @@ describe('chat store contract', () => { round_id: messageProperties.round_id, turn_index: 1, } - expect(chatAnalyticsMocks.trackMessageSendStarted).toHaveBeenCalledWith(expect.objectContaining(correlation)) - expect(chatAnalyticsMocks.trackLlmRequestStarted).toHaveBeenCalledWith(expect.objectContaining(correlation)) - expect(chatAnalyticsMocks.trackLlmFirstToken).toHaveBeenCalledWith(expect.objectContaining(correlation)) - expect(chatAnalyticsMocks.trackAssistantResponseRendered).toHaveBeenCalledWith(expect.objectContaining(correlation)) expect(chatAnalyticsMocks.trackMessageRound).toHaveBeenCalledWith(expect.objectContaining(correlation)) - expect(chatAnalyticsMocks.trackChatActivationStarted).toHaveBeenCalledWith(expect.objectContaining(correlation)) - expect(chatAnalyticsMocks.trackChatActivationSucceeded).toHaveBeenCalledWith(expect.objectContaining(correlation)) }) it('captures custom-provider usage once and leaves official generation capture to the server', async () => { @@ -469,7 +411,7 @@ describe('chat store contract', () => { }) }) - it('emits second turn analytics from chat sends', async () => { + it('uses turn_index on message_sent instead of a second-turn alias', async () => { activeProviderRef.value = 'official-provider' llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => { await options.onStreamEvent({ type: 'text-delta', text: 'ok' }) @@ -487,16 +429,13 @@ describe('chat store contract', () => { chatProvider: provider, }) - expect(trackSecondTurnStartedMock).toHaveBeenCalledTimes(1) - expect(trackSecondTurnStartedMock).toHaveBeenCalledWith({ + expect(chatAnalyticsMocks.trackMessageSent).toHaveBeenLastCalledWith(expect.objectContaining({ conversation_id: 'session-1', - provider_id: 'official-provider', - provider_mode: 'official', - model_id: 'chat-auto', round_id: expect.any(String), - source: 'text', turn_index: 2, - }) + trigger_method: 'text_input', + trigger_type: 'user_action', + })) }) // ROOT CAUSE: @@ -539,7 +478,6 @@ describe('chat store contract', () => { chatProvider: provider, })).rejects.toThrow('later turn rejected') - expect(chatAnalyticsMocks.trackChatActivationFailed).not.toHaveBeenCalled() expect(chatAnalyticsMocks.trackMessageRoundFailed).toHaveBeenCalledWith({ conversation_id: 'session-1', error_code: 'llm_response_failed', @@ -549,6 +487,8 @@ describe('chat store contract', () => { round_id: expect.any(String), source: 'text', turn_index: 2, + trigger_method: 'text_input', + trigger_type: 'user_flow_result', }) }) @@ -614,7 +554,7 @@ describe('chat store contract', () => { }) expect(store.sending).toBe(false) - expect(trackFirstMessageMock).toHaveBeenCalledTimes(1) + expect(trackFirstMessageMock).toHaveBeenCalledOnce() // Datetime is no longer pushed through ingestContextMessage; it is now // applied at message-assembly time as a system-prompt anchor + per-message // [HH:MM] prefix. ingestContextMessage should still be called for other diff --git a/packages/stage-ui/src/stores/modules/hearing.analytics.test.ts b/packages/stage-ui/src/stores/modules/hearing.analytics.test.ts index 77eddaaa6..3cc5ec950 100644 --- a/packages/stage-ui/src/stores/modules/hearing.analytics.test.ts +++ b/packages/stage-ui/src/stores/modules/hearing.analytics.test.ts @@ -3,10 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const analyticsMock = vi.hoisted(() => ({ allowComposableCall: true, - trackAudioDeviceUnavailable: vi.fn(), trackMicrophonePermissionDenied: vi.fn(), trackSttFailed: vi.fn(), - trackSttStarted: vi.fn(), trackSttSucceeded: vi.fn(), trackVoiceInputCancelled: vi.fn(), trackVoiceInputStarted: vi.fn(), @@ -22,10 +20,8 @@ vi.mock('../../composables/use-analytics', () => ({ throw new Error('Must be called at the top of a `setup` function') return { - trackAudioDeviceUnavailable: analyticsMock.trackAudioDeviceUnavailable, trackMicrophonePermissionDenied: analyticsMock.trackMicrophonePermissionDenied, trackSttFailed: analyticsMock.trackSttFailed, - trackSttStarted: analyticsMock.trackSttStarted, trackSttSucceeded: analyticsMock.trackSttSucceeded, trackVoiceInputCancelled: analyticsMock.trackVoiceInputCancelled, trackVoiceInputStarted: analyticsMock.trackVoiceInputStarted, @@ -48,10 +44,8 @@ describe('useHearingStore analytics lifecycle', () => { beforeEach(() => { setActivePinia(createPinia()) analyticsMock.allowComposableCall = true - analyticsMock.trackAudioDeviceUnavailable.mockReset() analyticsMock.trackMicrophonePermissionDenied.mockReset() analyticsMock.trackSttFailed.mockReset() - analyticsMock.trackSttStarted.mockReset() analyticsMock.trackSttSucceeded.mockReset() analyticsMock.trackVoiceInputCancelled.mockReset() analyticsMock.trackVoiceInputStarted.mockReset() @@ -81,7 +75,6 @@ describe('useHearingStore analytics lifecycle', () => { expect(analyticsMock.trackVoiceInputStarted).toHaveBeenCalledWith({ stt_provider_id: 'openai-compatible-audio-transcription', }) - expect(analyticsMock.trackSttStarted).toHaveBeenCalledWith('openai-compatible-audio-transcription') expect(analyticsMock.trackSttSucceeded).toHaveBeenCalledWith({ provider: 'openai-compatible-audio-transcription', latency_ms: expect.any(Number), @@ -117,6 +110,5 @@ describe('useHearingStore analytics lifecycle', () => { stt_provider_id: 'openai-compatible-audio-transcription', error_code: 'permission_denied', }) - expect(analyticsMock.trackAudioDeviceUnavailable).not.toHaveBeenCalled() }, 10000) }) diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 5f78830cd..0a2b8193a 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -316,10 +316,8 @@ export const useHearingStore = defineStore('hearing-store', () => { const providerStore = useProviderConfigStore() const { allAudioTranscriptionProvidersMetadata } = storeToRefs(providersStore) const { - trackAudioDeviceUnavailable, trackMicrophonePermissionDenied, trackSttFailed, - trackSttStarted, trackSttSucceeded, trackVoiceInputStarted, } = useAnalytics() @@ -424,7 +422,6 @@ export const useHearingStore = defineStore('hearing-store', () => { const sttStartedAt = performance.now() trackVoiceInputStarted({ stt_provider_id: providerId }) - trackSttStarted(providerId) function emitSucceeded(charCount: number, stream: boolean) { trackSttSucceeded({ @@ -443,12 +440,6 @@ export const useHearingStore = defineStore('hearing-store', () => { error_code: errorCode, }) } - if (errorCode === 'device_unavailable') { - trackAudioDeviceUnavailable({ - stt_provider_id: providerId, - error_code: errorCode, - }) - } } try { @@ -586,7 +577,6 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech onTranscriptionUpdate: (text: string) => streamingConsumers.emitTranscriptionUpdate(text), } const { - trackAudioDeviceUnavailable, trackVoiceInputCancelled, trackVoiceInputStarted, } = useAnalytics() @@ -1200,10 +1190,6 @@ export const useHearingSpeechInputPipeline = defineStore('modules:hearing:speech if (recording.size <= 0) { error.value = 'Recording captured from microphone is empty' - trackAudioDeviceUnavailable({ - stt_provider_id: activeTranscriptionProvider.value || 'unknown', - error_code: 'device_unavailable', - }) return }