💄 style: implement data analytics event tracking framework (#8352)

This commit is contained in:
Tsuki 2025-07-10 11:33:35 +08:00 committed by GitHub
parent 13e1cafb62
commit f433aca05f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 358 additions and 18 deletions

View file

@ -144,6 +144,7 @@
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/analytics": "^1.5.1",
"@lobehub/charts": "^2.0.0",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",

View file

@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { Flexbox } from 'react-layout-kit';
import MainInterfaceTracker from '@/components/Analytics/MainInterfaceTracker';
import BrandTextLoading from '@/components/Loading/BrandTextLoading';
import { LayoutProps } from '../type';
@ -31,6 +32,7 @@ const Layout = ({ children, topic, conversation, portal }: LayoutProps) => {
</Portal>
<TopicPanel>{topic}</TopicPanel>
</Flexbox>
<MainInterfaceTracker />
</>
);
};

View file

@ -1,3 +1,4 @@
import MainInterfaceTracker from '@/components/Analytics/MainInterfaceTracker';
import MobileContentLayout from '@/components/server/MobileNavLayout';
import { LayoutProps } from '../type';
@ -13,6 +14,7 @@ const Layout = ({ children, topic, conversation, portal }: LayoutProps) => {
</MobileContentLayout>
<TopicModal>{topic}</TopicModal>
{portal}
<MainInterfaceTracker />
</>
);
};

View file

@ -1,3 +1,4 @@
import { useAnalytics } from '@lobehub/analytics/react';
import { Empty } from 'antd';
import { createStyles } from 'antd-style';
import Link from 'next/link';
@ -9,8 +10,10 @@ import LazyLoad from 'react-lazy-load';
import { SESSION_CHAT_URL } from '@/const/url';
import { useSwitchSession } from '@/hooks/useSwitchSession';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { getSessionStoreState, useSessionStore } from '@/store/session';
import { sessionGroupSelectors, sessionSelectors } from '@/store/session/selectors';
import { getUserStoreState } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { LobeAgentSession } from '@/types/session';
import SkeletonList from '../../SkeletonList';
@ -29,6 +32,7 @@ interface SessionListProps {
}
const SessionList = memo<SessionListProps>(({ dataSource, groupId, showAddButton = true }) => {
const { t } = useTranslation('chat');
const { analytics } = useAnalytics();
const { styles } = useStyles();
const isInit = useSessionStore(sessionSelectors.isSessionListInit);
@ -49,6 +53,35 @@ const SessionList = memo<SessionListProps>(({ dataSource, groupId, showAddButton
onClick={(e) => {
e.preventDefault();
switchSession(id);
// Enhanced analytics tracking
if (analytics) {
const userStore = getUserStoreState();
const sessionStore = getSessionStoreState();
const userId = userProfileSelectors.userId(userStore);
const session = sessionSelectors.getSessionById(id)(sessionStore);
if (session) {
const sessionGroupId = session.group || 'default';
const group = sessionGroupSelectors.getGroupById(sessionGroupId)(sessionStore);
const groupName =
group?.name || (sessionGroupId === 'default' ? 'Default' : 'Unknown');
analytics?.track({
name: 'switch_session',
properties: {
assistant_name: session.meta?.title || 'Untitled Agent',
assistant_tags: session.meta?.tags || [],
group_id: sessionGroupId,
group_name: groupName,
session_id: id,
spm: 'homepage.chat.session_list_item.click',
user_id: userId || 'anonymous',
},
});
}
}
}}
>
<SessionItem id={id} />

View file

@ -0,0 +1,68 @@
'use client';
import { createSingletonAnalytics } from '@lobehub/analytics';
import { AnalyticsProvider } from '@lobehub/analytics/react';
import { ReactNode, memo, useMemo } from 'react';
import { BUSINESS_LINE } from '@/const/analytics';
import { isDesktop } from '@/const/version';
import { isDev } from '@/utils/env';
type Props = {
children: ReactNode;
debugPosthog: boolean;
posthogEnabled: boolean;
posthogHost: string;
posthogToken: string;
};
let analyticsInstance: ReturnType<typeof createSingletonAnalytics> | null = null;
export const LobeAnalyticsProvider = memo(
({ children, posthogHost, posthogToken, posthogEnabled, debugPosthog }: Props) => {
const analytics = useMemo(() => {
if (analyticsInstance) {
return analyticsInstance;
}
analyticsInstance = createSingletonAnalytics({
business: BUSINESS_LINE,
debug: isDev,
providers: {
posthog: {
debug: debugPosthog,
enabled: posthogEnabled,
host: posthogHost,
key: posthogToken,
person_profiles: 'always',
},
},
});
return analyticsInstance;
}, []);
if (!analytics) return children;
return (
<AnalyticsProvider
client={analytics}
onInitializeSuccess={() => {
analyticsInstance?.setGlobalContext({
platform: isDesktop ? 'desktop' : 'web',
});
analyticsInstance
?.getProvider('posthog')
?.getNativeInstance()
?.register({
platform: isDesktop ? 'desktop' : 'web',
});
}}
>
{children}
</AnalyticsProvider>
);
},
() => true,
);

View file

@ -0,0 +1,23 @@
import { ReactNode, memo } from 'react';
import { LobeAnalyticsProvider } from '@/components/Analytics/LobeAnalyticsProvider';
import { analyticsEnv } from '@/config/analytics';
type Props = {
children: ReactNode;
};
export const LobeAnalyticsProviderWrapper = memo<Props>(({ children }) => {
return (
<LobeAnalyticsProvider
debugPosthog={analyticsEnv.DEBUG_POSTHOG_ANALYTICS}
posthogEnabled={analyticsEnv.ENABLED_POSTHOG_ANALYTICS}
posthogHost={analyticsEnv.POSTHOG_HOST}
posthogToken={analyticsEnv.POSTHOG_KEY ?? ''}
>
{children}
</LobeAnalyticsProvider>
);
});
LobeAnalyticsProviderWrapper.displayName = 'LobeAnalyticsProviderWrapper';

View file

@ -0,0 +1,52 @@
'use client';
import { useAnalytics } from '@lobehub/analytics/react';
import { memo, useCallback, useEffect } from 'react';
import { getChatStoreState } from '@/store/chat';
import { chatSelectors } from '@/store/chat/slices/message/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { getSessionStoreState } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
const MainInterfaceTracker = memo(() => {
const { analytics } = useAnalytics();
const getMainInterfaceAnalyticsData = useCallback(() => {
const currentSession = sessionSelectors.currentSession(getSessionStoreState());
const activeSessionId = currentSession?.id;
const defaultSessions = sessionSelectors.defaultSessions(getSessionStoreState());
const showChatSideBar = systemStatusSelectors.showChatSideBar(useGlobalStore.getState());
const messages = chatSelectors.activeBaseChats(getChatStoreState());
return {
active_assistant: activeSessionId === 'inbox' ? null : currentSession?.meta?.title || null,
has_chat_history: messages.length > 0,
session_id: activeSessionId ? activeSessionId : 'inbox',
sidebar_state: showChatSideBar ? 'expanded' : 'collapsed',
visible_assistants_count: defaultSessions.length,
};
}, []);
useEffect(() => {
if (!analytics) return;
const timer = setTimeout(() => {
analytics.track({
name: 'main_page_view',
properties: {
...getMainInterfaceAnalyticsData(),
spm: 'main_page.interface.view',
},
});
}, 1000);
return () => clearTimeout(timer);
}, [analytics, getMainInterfaceAnalyticsData]);
return null;
});
MainInterfaceTracker.displayName = 'MainInterfaceTracker';
export default MainInterfaceTracker;

View file

@ -8,7 +8,6 @@ import Google from './Google';
import Vercel from './Vercel';
const Plausible = dynamic(() => import('./Plausible'));
const Posthog = dynamic(() => import('./Posthog'));
const Umami = dynamic(() => import('./Umami'));
const Clarity = dynamic(() => import('./Clarity'));
const ReactScan = dynamic(() => import('./ReactScan'));
@ -24,13 +23,6 @@ const Analytics = () => {
scriptBaseUrl={analyticsEnv.PLAUSIBLE_SCRIPT_BASE_URL}
/>
)}
{analyticsEnv.ENABLED_POSTHOG_ANALYTICS && (
<Posthog
debug={analyticsEnv.DEBUG_POSTHOG_ANALYTICS}
host={analyticsEnv.POSTHOG_HOST!}
token={analyticsEnv.POSTHOG_KEY}
/>
)}
{analyticsEnv.ENABLED_UMAMI_ANALYTICS && (
<Umami
scriptUrl={analyticsEnv.UMAMI_SCRIPT_URL}

3
src/const/analytics.ts Normal file
View file

@ -0,0 +1,3 @@
import { isDesktop } from '@/const/version';
export const BUSINESS_LINE = isDesktop ? 'lobe-chat-desktop' : 'lobe-chat';

View file

@ -1,3 +1,4 @@
import { getSingletonAnalyticsOptional } from '@lobehub/analytics';
import { DeepPartial } from 'utility-types';
import { StateCreator } from 'zustand/vanilla';
@ -260,7 +261,31 @@ export const store: StateCreator<Store, [['zustand/devtools', never]]> = (set, g
await get().dispatchConfig({ config, type: 'update' });
},
setAgentMeta: async (meta) => {
await get().dispatchMeta({ type: 'update', value: meta });
const { dispatchMeta, id, meta: currentMeta } = get();
const mergedMeta = merge(currentMeta, meta);
try {
const analytics = getSingletonAnalyticsOptional();
if (analytics) {
analytics.track({
name: 'agent_meta_updated',
properties: {
assistant_avatar: mergedMeta.avatar,
assistant_background_color: mergedMeta.backgroundColor,
assistant_description: mergedMeta.description,
assistant_name: mergedMeta.title,
assistant_tags: mergedMeta.tags,
is_inbox: id === 'inbox',
session_id: id || 'unknown',
timestamp: Date.now(),
user_id: useUserStore.getState().user?.id || 'anonymous',
},
});
}
} catch (error) {
console.warn('Failed to track agent meta update:', error);
}
await dispatchMeta({ type: 'update', value: meta });
},
setChatConfig: async (config) => {

View file

@ -1,8 +1,12 @@
import { useAnalytics } from '@lobehub/analytics/react';
import { useCallback, useMemo } from 'react';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { fileChatSelectors, useFileStore } from '@/store/file';
import { getUserStoreState } from '@/store/user';
import { SendMessageParams } from '@/types/message';
export type UseSendMessageParams = Pick<
@ -15,6 +19,7 @@ export const useSendMessage = () => {
s.sendMessage,
s.updateInputMessage,
]);
const { analytics } = useAnalytics();
const clearChatUploadFileList = useFileStore((s) => s.clearChatUploadFileList);
@ -49,6 +54,29 @@ export const useSendMessage = () => {
updateInputMessage('');
clearChatUploadFileList();
// 获取分析数据
const userStore = getUserStoreState();
const agentStore = getAgentStoreState();
// 直接使用现有数据结构判断消息类型
const hasImages = fileList.some((file) => file.file?.type?.startsWith('image'));
const messageType = fileList.length === 0 ? 'text' : hasImages ? 'image' : 'file';
analytics?.track({
name: 'send_message',
properties: {
chat_id: store.activeId || 'unknown',
current_topic: topicSelectors.currentActiveTopic(store)?.title || null,
has_attachments: fileList.length > 0,
history_message_count: chatSelectors.activeBaseChats(store).length,
message: store.inputMessage,
message_length: store.inputMessage.length,
message_type: messageType,
selected_model: agentSelectors.currentAgentModel(agentStore),
session_id: store.activeId || 'inbox', // 当前活跃的会话ID
user_id: userStore.user?.id || 'anonymous',
},
});
// const hasSystemRole = agentSelectors.hasSystemRole(useAgentStore.getState());
// const agentSetting = useAgentStore.getState().agentSettingInstance;

View file

@ -1,3 +1,4 @@
import { useAnalytics } from '@lobehub/analytics/react';
import { Button } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@ -7,12 +8,24 @@ import UserInfo from '../UserInfo';
const UserLoginOrSignup = memo<{ onClick: () => void }>(({ onClick }) => {
const { t } = useTranslation('auth');
const { analytics } = useAnalytics();
const handleClick = () => {
analytics?.track({
name: 'login_or_signup_clicked',
properties: {
spm: 'homepage.login_or_signup.click',
},
});
onClick();
};
return (
<>
<UserInfo />
<Flexbox paddingBlock={12} paddingInline={16} width={'100%'}>
<Button block onClick={onClick} type={'primary'}>
<Button block onClick={handleClick} type={'primary'}>
{t('loginOrSignup')}
</Button>
</Flexbox>

View file

@ -1,5 +1,6 @@
import { ReactNode, Suspense } from 'react';
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
import { appEnv } from '@/envs/app';
import DevPanel from '@/features/DevPanel';
@ -54,7 +55,9 @@ const GlobalLayout = async ({
isMobile={isMobile}
serverConfig={serverConfig}
>
<QueryProvider>{children}</QueryProvider>
<QueryProvider>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
</QueryProvider>
<StoreInitialization />
<Suspense>
<ImportSettings />

View file

@ -0,0 +1,25 @@
import { createServerAnalytics } from '@lobehub/analytics/server';
import { analyticsEnv } from '@/config/analytics';
import { BUSINESS_LINE } from '@/const/analytics';
import { isDev } from '@/utils/env';
export const serverAnalytics = createServerAnalytics({
business: BUSINESS_LINE,
debug: isDev,
providers: {
posthogNode: {
debug: analyticsEnv.DEBUG_POSTHOG_ANALYTICS,
enabled: analyticsEnv.ENABLED_POSTHOG_ANALYTICS,
host: analyticsEnv.POSTHOG_HOST,
key: analyticsEnv.POSTHOG_KEY ?? '',
},
},
});
export const initializeServerAnalytics = async () => {
await serverAnalytics.initialize();
return serverAnalytics;
};
export default serverAnalytics;

View file

@ -8,6 +8,14 @@ import { AgentService } from '@/server/services/agent';
import { UserService } from './index';
// Mock @/libs/analytics to avoid server-side environment variable access in client test environment
vi.mock('@/libs/analytics', () => ({
initializeServerAnalytics: vi.fn().mockResolvedValue({
identify: vi.fn(),
track: vi.fn(),
}),
}));
// Mock dependencies
vi.mock('@/database/models/user', () => {
const MockUserModel = vi.fn();

View file

@ -2,6 +2,7 @@ import { UserJSON } from '@clerk/backend';
import { UserModel } from '@/database/models/user';
import { serverDB } from '@/database/server';
import { initializeServerAnalytics } from '@/libs/analytics';
import { pino } from '@/libs/logger';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { S3 } from '@/server/modules/S3';
@ -51,6 +52,23 @@ export class UserService {
/* ↑ cloud slot ↑ */
//analytics
const analytics = await initializeServerAnalytics();
analytics?.identify(id, {
email: email?.email_address,
firstName: params.first_name,
lastName: params.last_name,
phone: phone?.phone_number,
username: params.username,
});
analytics?.track({
name: 'user_register_completed',
properties: {
spm: 'user_service.create_user.user_created',
},
userId: id,
});
return { message: 'user created', success: true };
};

View file

@ -1,3 +1,4 @@
import { getSingletonAnalyticsOptional } from '@lobehub/analytics';
import isEqual from 'fast-deep-equal';
import { t } from 'i18next';
import useSWR, { SWRResponse, mutate } from 'swr';
@ -10,8 +11,8 @@ import { DEFAULT_AGENT_LOBE_SESSION, INBOX_SESSION_ID } from '@/const/session';
import { useClientDataSWR } from '@/libs/swr';
import { sessionService } from '@/services/session';
import { SessionStore } from '@/store/session';
import { useUserStore } from '@/store/user';
import { settingsSelectors } from '@/store/user/selectors';
import { getUserStoreState, useUserStore } from '@/store/user';
import { settingsSelectors, userProfileSelectors } from '@/store/user/selectors';
import { MetaData } from '@/types/meta';
import {
ChatSessionList,
@ -24,6 +25,7 @@ import {
import { merge } from '@/utils/merge';
import { setNamespace } from '@/utils/storeDebug';
import { sessionGroupSelectors } from '../sessionGroup/selectors';
import { SessionDispatch, sessionsReducer } from './reducers';
import { sessionSelectors } from './selectors';
import { sessionMetaSelectors } from './selectors/meta';
@ -114,6 +116,30 @@ export const createSessionSlice: StateCreator<
const id = await sessionService.createSession(LobeSessionType.Agent, newSession);
await refreshSessions();
// Track new agent creation analytics
const analytics = getSingletonAnalyticsOptional();
if (analytics) {
const userStore = getUserStoreState();
const userId = userProfileSelectors.userId(userStore);
// Get group information
const groupId = newSession.group || 'default';
const group = sessionGroupSelectors.getGroupById(groupId)(get());
const groupName = group?.name || (groupId === 'default' ? 'Default' : 'Unknown');
analytics.track({
name: 'new_agent_created',
properties: {
assistant_name: newSession.meta?.title || 'Untitled Agent',
assistant_tags: newSession.meta?.tags || [],
group_id: groupId,
group_name: groupName,
session_id: id,
user_id: userId || 'anonymous',
},
});
}
// Whether to goto to the new session after creation, the default is to switch to
if (isSwitchSession) switchSession(id);

View file

@ -1,3 +1,4 @@
import { getSingletonAnalyticsOptional } from '@lobehub/analytics';
import useSWR, { SWRResponse, mutate } from 'swr';
import { DeepPartial } from 'utility-types';
import type { StateCreator } from 'zustand/vanilla';
@ -22,7 +23,6 @@ const GET_USER_STATE_KEY = 'initUserState';
*/
export interface CommonAction {
refreshUserState: () => Promise<void>;
updateAvatar: (avatar: string) => Promise<void>;
useCheckTrace: (shouldFetch: boolean) => SWRResponse;
useInitUserState: (
@ -118,7 +118,14 @@ export const createCommonSlice: StateCreator<
false,
n('initUserState'),
);
//analytics
const analytics = getSingletonAnalyticsOptional();
analytics?.identify(data.userId || '', {
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
username: data.username,
});
get().refreshDefaultModelProviderList({ trigger: 'fetchUserState' });
}
},

View file

@ -5,6 +5,17 @@ import { theme } from 'antd';
// refs: https://github.com/dumbmatter/fakeIndexedDB#dexie-and-other-indexeddb-api-wrappers
import 'fake-indexeddb/auto';
import React from 'react';
import { vi } from 'vitest';
// Global mock for @lobehub/analytics/react to avoid AnalyticsProvider dependency
// This prevents tests from failing when components use useAnalytics hook
vi.mock('@lobehub/analytics/react', () => ({
useAnalytics: () => ({
analytics: {
track: vi.fn(),
},
}),
}));
// only inject in the dom environment
if (