diff --git a/packages/node-runtime/src/services/analytics.test.ts b/packages/node-runtime/src/services/analytics.test.ts index fb4450e2..dfb0b694 100644 --- a/packages/node-runtime/src/services/analytics.test.ts +++ b/packages/node-runtime/src/services/analytics.test.ts @@ -144,6 +144,44 @@ test('track normalizes the shared locale instead of accepting arbitrary event da } }) +test('insight telemetry accepts only public tab categories and never forwards chat identifiers', async (t) => { + const systemDir = createTempSystemDir() + t.after(() => rmSync(systemDir, { recursive: true, force: true })) + const bodies: Array<{ payload: { name: string; url: string; data: Record } }> = [] + t.mock.method(globalThis, 'fetch', async (_input: unknown, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))) + return new Response(null, { status: 204 }) + }) + const service = new AnalyticsService(systemDir, createOptions()) + assert.equal(await service.track('insight_viewed', { chat_type: 'group' }), true) + assert.equal( + await service.track('insight_tab_used', { + chat_type: 'private', + tab_id: 'journey', + session_id: 'secret-chat', + name: 'Alice', + text: 'private message', + }), + true + ) + assert.equal(bodies[0].payload.url, '/insights/group') + assert.equal(bodies[1].payload.url, '/insights/private/journey') + assert.equal(bodies[1].payload.data.chat_type, 'private') + assert.equal(bodies[1].payload.data.tab_id, 'journey') + assert.equal(JSON.stringify(bodies).includes('secret-chat'), false) + assert.equal('name' in bodies[1].payload.data, false) + assert.equal('text' in bodies[1].payload.data, false) + for (const props of [ + { chat_type: 'group', tab_id: 'journey' }, + { chat_type: 'group', tab_id: '/private/path' }, + { chat_type: 'secret-chat', tab_id: 'overview' }, + { chat_type: 'private' }, + ]) + assert.equal(await service.track('insight_tab_used', props), false) + assert.equal(await service.track('insight_viewed', { chat_type: 'secret-chat' }), false) + assert.equal(bodies.length, 2) +}) + test('anonymous id persists across AnalyticsService instances', async () => { const systemDir = createTempSystemDir() const originalFetch = globalThis.fetch diff --git a/packages/node-runtime/src/services/analytics.ts b/packages/node-runtime/src/services/analytics.ts index fe7314f1..68baf6ff 100644 --- a/packages/node-runtime/src/services/analytics.ts +++ b/packages/node-runtime/src/services/analytics.ts @@ -52,6 +52,8 @@ const EVENT_NAMES = new Set([ 'chat_import_failed', 'incremental_import_used', 'feature_used', + 'insight_viewed', + 'insight_tab_used', 'ai_setup_completed', 'ai_request_started', 'ai_request_completed', @@ -79,6 +81,19 @@ const FAILURE_REASONS = new Set([ 'unknown', ]) +const INSIGHT_TABS = { + group: new Set(['overview', 'type-analysis', 'time-analysis', 'topic', 'relationship']), + private: new Set([ + 'overview', + 'type-analysis', + 'time-analysis', + 'topic', + 'relationship', + 'journey', + 'language-preference', + ]), +} + const APP_LOCALES = new Set(['zh-CN', 'zh-TW', 'en-US', 'ja-JP']) function getUmamiConfigFromEnv(): AnalyticsServiceOptions['umami'] | undefined { @@ -135,6 +150,15 @@ function normalizeEvent(eventName: string, props: Record = {}): properties.feature_id = props.feature_id } + if (name === 'insight_viewed' || name === 'insight_tab_used') { + if (props.chat_type !== 'group' && props.chat_type !== 'private') return null + properties.chat_type = props.chat_type + if (name === 'insight_tab_used') { + if (typeof props.tab_id !== 'string' || !INSIGHT_TABS[props.chat_type].has(props.tab_id)) return null + properties.tab_id = props.tab_id + } + } + if (name === 'ai_request_completed') { if (typeof props.success !== 'boolean') return null properties.success = props.success @@ -149,6 +173,8 @@ function normalizeEvent(eventName: string, props: Record = {}): } function eventPath(event: NormalizedEvent): string { + if (event.name === 'insight_viewed') return `/insights/${event.properties.chat_type}` + if (event.name === 'insight_tab_used') return `/insights/${event.properties.chat_type}/${event.properties.tab_id}` if (event.name === 'incremental_import_used') return '/import/incremental' if (event.name.startsWith('chat_import_')) return '/import' if (event.name.startsWith('ai_')) return '/ai' diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 278e4741..c6a6118c 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -1440,6 +1440,8 @@ export type AnalyticsEventName = | 'chat_import_failed' | 'incremental_import_used' | 'feature_used' + | 'insight_viewed' + | 'insight_tab_used' | 'ai_setup_completed' | 'ai_request_started' | 'ai_request_completed' diff --git a/src/components/navigation/NavigationTabsBase.vue b/src/components/navigation/NavigationTabsBase.vue index d597a517..4dbaef41 100644 --- a/src/components/navigation/NavigationTabsBase.vue +++ b/src/components/navigation/NavigationTabsBase.vue @@ -17,6 +17,7 @@ interface Props { interface Emits { (e: 'update:modelValue', value: string): void (e: 'change', value: string): void + (e: 'select', value: string): void } const props = withDefaults(defineProps(), { @@ -40,6 +41,13 @@ const activeTab = computed({ }, }) +function selectTab(value: string) { + const changed = value !== activeTab.value + activeTab.value = value + // Restore/model changes are not user selections; reselecting the active tab is not a visit. + if (changed) emit('select', value) +} + function updateIndicator() { const activeButton = tabRefs.value[activeTab.value] if (!activeButton || !containerRef.value) return @@ -128,7 +136,7 @@ watch( ? 'text-primary-600 dark:text-primary-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300', ]" - @click="activeTab = tab.id" + @click="selectTab(tab.id)" > {{ tab.label }} diff --git a/src/components/navigation/SectionTabs.vue b/src/components/navigation/SectionTabs.vue index b2195f04..d2183aed 100644 --- a/src/components/navigation/SectionTabs.vue +++ b/src/components/navigation/SectionTabs.vue @@ -10,6 +10,7 @@ defineProps<{ const model = defineModel({ required: true }) const emit = defineEmits<{ change: [value: string] + select: [value: string] }>() @@ -22,6 +23,7 @@ const emit = defineEmits<{ size="sm" :bordered="false" @change="emit('change', $event)" + @select="emit('select', $event)" >