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
}