feat(analytics): track deliberate insight tab visits after dwell

This commit is contained in:
digua committed 2026-09-08 15:52:11 +08:00
1 parent 16cbdb96b6
commit b9903e77e6
11 files changed
+257 -3

No files matched your search

@@ -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<string, unknown> } }> = []
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
@@ -52,6 +52,8 @@ const EVENT_NAMES = new Set<AnalyticsEventName>([
'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<string, unknown> = {}):
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<string, unknown> = {}):
}
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'
+2
View File
@@ -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'
@@ -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<Props>(), {
@@ -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)"
>
<UiIcon v-if="tab.icon" :name="tab.icon" :size="size === 'sm' ? 'sm' : 'md'" />
{{ tab.label }}
@@ -10,6 +10,7 @@ defineProps<{
const model = defineModel<string>({ required: true })
const emit = defineEmits<{
change: [value: string]
select: [value: string]
}>()
</script>
@@ -22,6 +23,7 @@ const emit = defineEmits<{
size="sm"
:bordered="false"
@change="emit('change', $event)"
@select="emit('select', $event)"
>
<template v-if="$slots.right" #right>
<slot name="right" />
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { effectScope, ref } from 'vue'
test('only uninterrupted foreground selections count; initial views and cancellations do not', async (t) => {
const events: Array<{ name: string; props: Record<string, unknown> }> = []
await t.mock.module('@/services/product-analytics', {
namedExports: { trackProductEvent: (name: string, props: Record<string, unknown>) => events.push({ name, props }) },
})
const { useInsightTabAnalytics } = await import('./useInsightTabAnalytics')
const browserWindow = new EventTarget()
const browserDocument = Object.assign(new EventTarget(), { visibilityState: 'visible', hasFocus: () => true })
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document')
Object.defineProperty(globalThis, 'window', { configurable: true, value: browserWindow })
Object.defineProperty(globalThis, 'document', { configurable: true, value: browserDocument })
const scope = effectScope()
t.after(() => {
scope.stop()
for (const [key, descriptor] of [
['window', originalWindow],
['document', originalDocument],
] as const) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor)
else Reflect.deleteProperty(globalThis, key)
}
})
t.mock.timers.enable({ apis: ['setTimeout'] })
const active = ref(true)
const sessionId = ref<string | null>('local-only-session')
const routePath = ref('/private-chat/local-only-session')
const select = scope.run(() =>
useInsightTabAnalytics({
chatType: 'private',
isActive: () => active.value,
sessionId,
routePath: () => routePath.value,
})
)!
const visits = () => events.filter(({ name }) => name === 'insight_tab_used')
t.mock.timers.tick(5000)
assert.deepEqual(events, [{ name: 'insight_viewed', props: { chat_type: 'private' } }])
select('topic')
t.mock.timers.tick(2000)
select('relationship')
t.mock.timers.tick(2999)
assert.equal(visits().length, 0)
t.mock.timers.tick(1)
assert.deepEqual(visits(), [{ name: 'insight_tab_used', props: { chat_type: 'private', tab_id: 'relationship' } }])
assert.equal(events.filter(({ name }) => name === 'insight_viewed').length, 2)
t.mock.timers.tick(5000)
assert.equal(visits().length, 1)
const interruptions = [
() => {
active.value = false
},
() => {
sessionId.value = 'another-local-session'
},
() => {
browserWindow.dispatchEvent(new Event('blur'))
},
() => {
browserDocument.visibilityState = 'hidden'
browserDocument.dispatchEvent(new Event('visibilitychange'))
},
() => {
routePath.value = '/home'
},
]
for (const interrupt of interruptions) {
select('time-analysis')
t.mock.timers.tick(2999)
interrupt()
active.value = true
browserDocument.visibilityState = 'visible'
t.mock.timers.tick(5000)
assert.equal(visits().length, 1)
}
// A later deliberate visit qualifies again, including a return to overview.
select('overview')
t.mock.timers.tick(3000)
assert.equal(visits().length, 2)
select('topic')
scope.stop()
t.mock.timers.tick(3000)
assert.equal(visits().length, 2)
assert.ok(events.every(({ props }) => !('session_id' in props)))
})
+57
View File
@@ -0,0 +1,57 @@
import { onScopeDispose, watch, type Ref } from 'vue'
import { trackProductEvent } from '@/services/product-analytics'
/** Counts deliberate tab visits, not default/restored selection or time in the background. */
export function useInsightTabAnalytics(options: {
chatType: 'group' | 'private'
isActive: () => boolean
sessionId: Ref<string | null>
routePath: () => string
}) {
let timer: ReturnType<typeof setTimeout> | undefined
function cancel() {
clearTimeout(timer)
timer = undefined
}
const isForeground = () => document.visibilityState === 'visible' && document.hasFocus()
function selectTab(tabId: string) {
cancel()
if (!options.isActive() || !isForeground()) return
timer = setTimeout(() => {
timer = undefined
if (options.isActive() && isForeground()) {
// A window may stay open across days; qualify this period's denominator as well.
trackProductEvent('insight_viewed', { chat_type: options.chatType })
trackProductEvent('insight_tab_used', { chat_type: options.chatType, tab_id: tabId })
}
}, 3000)
}
watch(
[options.isActive, options.sessionId],
([active, sessionId]) => {
cancel()
// Separate denominator: old clients without sub-tab tracking must not dilute its adoption rate.
if (active && sessionId) trackProductEvent('insight_viewed', { chat_type: options.chatType })
},
{ immediate: true, flush: 'sync' }
)
function onVisibilityChange() {
if (document.visibilityState !== 'visible') cancel()
}
window.addEventListener('blur', cancel)
// Cancel as soon as navigation commits, before an outgoing page transition finishes.
watch(options.routePath, cancel, { flush: 'sync' })
document.addEventListener('visibilitychange', onVisibilityChange)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
document.removeEventListener('visibilitychange', onVisibilityChange)
})
return selectTab
}
@@ -42,6 +42,7 @@ const subTabs = computed(() => {
})
const activeSubTab = ref('overview')
const emit = defineEmits<{ selectTab: [value: string] }>()
const selectedMemberId = ref<number | null>(null)
@@ -53,7 +54,12 @@ const viewTimeFilter = computed(() => ({
<template>
<div class="flex h-full flex-col">
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="groupInsightsTab">
<SectionTabs
v-model="activeSubTab"
:items="subTabs"
persist-key="groupInsightsTab"
@select="emit('selectTab', $event)"
>
<template #right>
<UserSelect
v-if="activeSubTab === 'type-analysis' || activeSubTab === 'time-analysis'"
+9
View File
@@ -21,6 +21,7 @@ import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
import { useSessionAnalysisPageBase } from '@/composables'
import { useInsightTabAnalytics } from '@/composables/useInsightTabAnalytics'
const { t } = useI18n()
@@ -88,6 +89,13 @@ const {
provide('session-switch-loading', isSessionSwitching)
const trackInsightTab = useInsightTabAnalytics({
chatType: 'group',
isActive: () => activeTab.value === 'insights' && !isSessionSwitching.value,
sessionId: currentSessionId,
routePath: () => route.path,
})
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -153,6 +161,7 @@ const filteredMemberCount = computed(() => {
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@select-tab="trackInsightTab"
/>
<RankingView
v-else-if="activeTab === 'ranking'"
@@ -44,6 +44,7 @@ const subTabs = computed(() => [
])
const activeSubTab = ref('overview')
const emit = defineEmits<{ selectTab: [value: string] }>()
// 成员筛选(用于类型/时间分析)
const selectedMemberId = ref<number | null>(null)
@@ -56,7 +57,12 @@ const viewTimeFilter = computed(() => ({
<template>
<div class="flex h-full flex-col">
<SectionTabs v-model="activeSubTab" :items="subTabs" persist-key="privateInsightsTab">
<SectionTabs
v-model="activeSubTab"
:items="subTabs"
persist-key="privateInsightsTab"
@select="emit('selectTab', $event)"
>
<template #right>
<UserSelect
v-if="activeSubTab === 'type-analysis' || activeSubTab === 'time-analysis'"
+9
View File
@@ -19,6 +19,7 @@ import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
import { useSessionAnalysisPageBase } from '@/composables'
import { useInsightTabAnalytics } from '@/composables/useInsightTabAnalytics'
const { t } = useI18n()
@@ -83,6 +84,13 @@ const {
provide('session-switch-loading', isSessionSwitching)
const trackInsightTab = useInsightTabAnalytics({
chatType: 'private',
isActive: () => activeTab.value === 'insights' && !isSessionSwitching.value,
sessionId: currentSessionId,
routePath: () => route.path,
})
// 当前筛选后的消息总数
const filteredMessageCount = computed(() => {
return memberActivity.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -173,6 +181,7 @@ const otherMemberAvatar = computed(() => {
:filtered-message-count="filteredMessageCount"
:filtered-member-count="filteredMemberCount"
:time-filter="timeFilter"
@select-tab="trackInsightTab"
/>
<ChatExplorer
v-else-if="activeTab === 'ai-chat'"