mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(analytics): track deliberate insight tab visits after dwell
This commit is contained in:
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'
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
@@ -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'"
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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'"
|
||||
|
||||
Reference in new issue
Block a user