integrate Amplitude analytics into the application (#29049)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
Coding On Star 2025-12-03 14:22:12 +08:00 committed by GitHub
parent c7d2a13524
commit fbb2d076f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2066 additions and 1697 deletions

View File

@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import SwrInitializer from '@/app/components/swr-initializer' import SwrInitializer from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context' import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga' import GA, { GaType } from '@/app/components/base/ga'
import AmplitudeProvider from '@/app/components/base/amplitude'
import HeaderWrapper from '@/app/components/header/header-wrapper' import HeaderWrapper from '@/app/components/header/header-wrapper'
import Header from '@/app/components/header' import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter'
@ -18,6 +19,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
<> <>
<GA gaType={GaType.admin} /> <GA gaType={GaType.admin} />
<AmplitudeProvider />
<SwrInitializer> <SwrInitializer>
<AppContextProvider> <AppContextProvider>
<EventEmitterContextProvider> <EventEmitterContextProvider>

View File

@ -12,6 +12,7 @@ import { useProviderContext } from '@/context/provider-context'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { resetUser } from '@/app/components/base/amplitude/utils'
export type IAppSelector = { export type IAppSelector = {
isMobile: boolean isMobile: boolean
@ -28,6 +29,7 @@ export default function AppSelector() {
await logout() await logout()
localStorage.removeItem('setup_status') localStorage.removeItem('setup_status')
resetUser()
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend
router.push('/signin') router.push('/signin')

View File

@ -4,6 +4,7 @@ import Header from './header'
import SwrInitor from '@/app/components/swr-initializer' import SwrInitor from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context' import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga' import GA, { GaType } from '@/app/components/base/ga'
import AmplitudeProvider from '@/app/components/base/amplitude'
import HeaderWrapper from '@/app/components/header/header-wrapper' import HeaderWrapper from '@/app/components/header/header-wrapper'
import { EventEmitterContextProvider } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context' import { ProviderContextProvider } from '@/context/provider-context'
@ -13,6 +14,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
<> <>
<GA gaType={GaType.admin} /> <GA gaType={GaType.admin} />
<AmplitudeProvider />
<SwrInitor> <SwrInitor>
<AppContextProvider> <AppContextProvider>
<EventEmitterContextProvider> <EventEmitterContextProvider>

View File

@ -28,6 +28,7 @@ import Input from '@/app/components/base/input'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { DSLImportMode } from '@/models/app' import { DSLImportMode } from '@/models/app'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { trackEvent } from '@/app/components/base/amplitude'
type AppsProps = { type AppsProps = {
onSuccess?: () => void onSuccess?: () => void
@ -141,6 +142,15 @@ const Apps = ({
icon_background, icon_background,
description, description,
}) })
// Track app creation from template
trackEvent('create_app_with_template', {
app_mode: mode,
template_id: currApp?.app.id,
template_name: currApp?.app.name,
description,
})
setIsShowCreateModal(false) setIsShowCreateModal(false)
Toast.notify({ Toast.notify({
type: 'success', type: 'success',

View File

@ -30,6 +30,7 @@ import { getRedirection } from '@/utils/app-redirection'
import FullScreenModal from '@/app/components/base/fullscreen-modal' import FullScreenModal from '@/app/components/base/fullscreen-modal'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { trackEvent } from '@/app/components/base/amplitude'
type CreateAppProps = { type CreateAppProps = {
onSuccess: () => void onSuccess: () => void
@ -82,6 +83,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
mode: appMode, mode: appMode,
}) })
// Track app creation success
trackEvent('create_app', {
app_mode: appMode,
description,
})
notify({ type: 'success', message: t('app.newApp.appCreated') }) notify({ type: 'success', message: t('app.newApp.appCreated') })
onSuccess() onSuccess()
onClose() onClose()

View File

@ -28,6 +28,7 @@ import { getRedirection } from '@/utils/app-redirection'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { trackEvent } from '@/app/components/base/amplitude'
type CreateFromDSLModalProps = { type CreateFromDSLModalProps = {
show: boolean show: boolean
@ -112,6 +113,13 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
return return
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
// Track app creation from DSL import
trackEvent('create_app_with_dsl', {
app_mode,
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
})
if (onSuccess) if (onSuccess)
onSuccess() onSuccess()
if (onClose) if (onClose)

View File

@ -8,6 +8,7 @@ import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import type { QueryParam } from './index' import type { QueryParam } from './index'
import Chip from '@/app/components/base/chip' import Chip from '@/app/components/base/chip'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { trackEvent } from '@/app/components/base/amplitude/utils'
dayjs.extend(quarterOfYear) dayjs.extend(quarterOfYear)
const today = dayjs() const today = dayjs()
@ -37,6 +38,9 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
value={queryParams.status || 'all'} value={queryParams.status || 'all'}
onSelect={(item) => { onSelect={(item) => {
setQueryParams({ ...queryParams, status: item.value as string }) setQueryParams({ ...queryParams, status: item.value as string })
trackEvent('workflow_log_filter_status_selected', {
workflow_log_filter_status: item.value as string,
})
}} }}
onClear={() => setQueryParams({ ...queryParams, status: 'all' })} onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
items={[{ value: 'all', name: 'All' }, items={[{ value: 'all', name: 'All' },

View File

@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { IS_CLOUD_EDITION } from '@/config'
export type IAmplitudeProps = {
apiKey?: string
sessionReplaySampleRate?: number
}
const AmplitudeProvider: FC<IAmplitudeProps> = ({
apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '',
sessionReplaySampleRate = 1,
}) => {
useEffect(() => {
// Only enable in Saas edition
if (!IS_CLOUD_EDITION)
return
// Initialize Amplitude
amplitude.init(apiKey, {
defaultTracking: {
sessions: true,
pageViews: true,
formInteractions: true,
fileDownloads: true,
},
// Enable debug logs in development environment
logLevel: amplitude.Types.LogLevel.Warn,
})
// Add Session Replay plugin
const sessionReplay = sessionReplayPlugin({
sampleRate: sessionReplaySampleRate,
})
amplitude.add(sessionReplay)
}, [])
// This is a client component that renders nothing
return null
}
export default React.memo(AmplitudeProvider)

View File

@ -0,0 +1,2 @@
export { default } from './AmplitudeProvider'
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'

View File

@ -0,0 +1,37 @@
import * as amplitude from '@amplitude/analytics-browser'
/**
* Track custom event
* @param eventName Event name
* @param eventProperties Event properties (optional)
*/
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
amplitude.track(eventName, eventProperties)
}
/**
* Set user ID
* @param userId User ID
*/
export const setUserId = (userId: string) => {
amplitude.setUserId(userId)
}
/**
* Set user properties
* @param properties User properties
*/
export const setUserProperties = (properties: Record<string, any>) => {
const identifyEvent = new amplitude.Identify()
Object.entries(properties).forEach(([key, value]) => {
identifyEvent.set(key, value)
})
amplitude.identify(identifyEvent)
}
/**
* Reset user (e.g., when user logs out)
*/
export const resetUser = () => {
amplitude.reset()
}

View File

@ -34,6 +34,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { resetUser } from '@/app/components/base/amplitude/utils'
export default function AppSelector() { export default function AppSelector() {
const itemClassName = ` const itemClassName = `
@ -53,7 +54,7 @@ export default function AppSelector() {
const { mutateAsync: logout } = useLogout() const { mutateAsync: logout } = useLogout()
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
resetUser()
localStorage.removeItem('setup_status') localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend

View File

@ -11,6 +11,7 @@ import Toast from '@/app/components/base/toast'
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
import I18NContext from '@/context/i18n' import I18NContext from '@/context/i18n'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect' import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
import { trackEvent } from '@/app/components/base/amplitude'
export default function CheckCode() { export default function CheckCode() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
@ -44,6 +45,12 @@ export default function CheckCode() {
setIsLoading(true) setIsLoading(true)
const ret = await emailLoginWithCode({ email, code, token, language }) const ret = await emailLoginWithCode({ email, code, token, language })
if (ret.result === 'success') { if (ret.result === 'success') {
// Track login success event
trackEvent('user_login_success', {
method: 'email_code',
is_invite: !!invite_token,
})
if (invite_token) { if (invite_token) {
router.replace(`/signin/invite-settings?${searchParams.toString()}`) router.replace(`/signin/invite-settings?${searchParams.toString()}`)
} }

View File

@ -12,6 +12,7 @@ import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect' import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
import type { ResponseError } from '@/service/fetch' import type { ResponseError } from '@/service/fetch'
import { trackEvent } from '@/app/components/base/amplitude'
type MailAndPasswordAuthProps = { type MailAndPasswordAuthProps = {
isInvite: boolean isInvite: boolean
@ -63,6 +64,12 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
body: loginData, body: loginData,
}) })
if (res.result === 'success') { if (res.result === 'success') {
// Track login success event
trackEvent('user_login_success', {
method: 'email_password',
is_invite: isInvite,
})
if (isInvite) { if (isInvite) {
router.replace(`/signin/invite-settings?${searchParams.toString()}`) router.replace(`/signin/invite-settings?${searchParams.toString()}`)
} }

View File

@ -42,7 +42,6 @@ export default function CheckCode() {
} }
setIsLoading(true) setIsLoading(true)
const res = await verifyCode({ email, code, token }) const res = await verifyCode({ email, code, token })
console.log(res)
if ((res as MailValidityResponse).is_valid) { if ((res as MailValidityResponse).is_valid) {
const params = new URLSearchParams(searchParams) const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent((res as MailValidityResponse).token)) params.set('token', encodeURIComponent((res as MailValidityResponse).token))

View File

@ -9,6 +9,7 @@ import Input from '@/app/components/base/input'
import { validPassword } from '@/config' import { validPassword } from '@/config'
import type { MailRegisterResponse } from '@/service/use-common' import type { MailRegisterResponse } from '@/service/use-common'
import { useMailRegister } from '@/service/use-common' import { useMailRegister } from '@/service/use-common'
import { trackEvent } from '@/app/components/base/amplitude'
const ChangePasswordForm = () => { const ChangePasswordForm = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -54,6 +55,11 @@ const ChangePasswordForm = () => {
}) })
const { result } = res as MailRegisterResponse const { result } = res as MailRegisterResponse
if (result === 'success') { if (result === 'success') {
// Track registration success event
trackEvent('user_registration_success', {
method: 'email',
})
Toast.notify({ Toast.notify({
type: 'success', type: 'success',
message: t('common.api.actionSuccess'), message: t('common.api.actionSuccess'),

View File

@ -11,6 +11,7 @@ import { noop } from 'lodash-es'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config' import { ZENDESK_FIELD_IDS } from '@/config'
import { useGlobalPublicStore } from './global-public-context' import { useGlobalPublicStore } from './global-public-context'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
export type AppContextValue = { export type AppContextValue = {
userProfile: UserProfileResponse userProfile: UserProfileResponse
@ -159,6 +160,28 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
}, [currentWorkspace?.id]) }, [currentWorkspace?.id])
// #endregion Zendesk conversation fields // #endregion Zendesk conversation fields
useEffect(() => {
// Report user and workspace info to Amplitude when loaded
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, any> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
}
}, [userProfile, currentWorkspace])
return ( return (
<AppContext.Provider value={{ <AppContext.Provider value={{
userProfile, userProfile,

View File

@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://api.github.com' const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://api.github.com https://api2.amplitude.com *.amplitude.com'
const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => { const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => {
// prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking // prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking

View File

@ -45,6 +45,8 @@
"knip": "knip" "knip": "knip"
}, },
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "^2.31.3",
"@amplitude/plugin-session-replay-browser": "^1.23.6",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@floating-ui/react": "^0.26.28", "@floating-ui/react": "^0.26.28",
"@formatjs/intl-localematcher": "^0.5.10", "@formatjs/intl-localematcher": "^0.5.10",

File diff suppressed because it is too large Load Diff