mirror of https://github.com/langgenius/dify.git
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:
parent
c7d2a13524
commit
fbb2d076f4
|
|
@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
|||
import SwrInitializer from '@/app/components/swr-initializer'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import Header from '@/app/components/header'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
|
|
@ -18,6 +19,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useProviderContext } from '@/context/provider-context'
|
|||
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
|
||||
export type IAppSelector = {
|
||||
isMobile: boolean
|
||||
|
|
@ -28,6 +29,7 @@ export default function AppSelector() {
|
|||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
resetUser()
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Header from './header'
|
|||
import SwrInitor from '@/app/components/swr-initializer'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter'
|
||||
import { ProviderContextProvider } from '@/context/provider-context'
|
||||
|
|
@ -13,6 +14,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<SwrInitor>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import Input from '@/app/components/base/input'
|
|||
import { AppModeEnum } from '@/types/app'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type AppsProps = {
|
||||
onSuccess?: () => void
|
||||
|
|
@ -141,6 +142,15 @@ const Apps = ({
|
|||
icon_background,
|
||||
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)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { getRedirection } from '@/utils/app-redirection'
|
|||
import FullScreenModal from '@/app/components/base/fullscreen-modal'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type CreateAppProps = {
|
||||
onSuccess: () => void
|
||||
|
|
@ -82,6 +83,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
|||
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
// Track app creation success
|
||||
trackEvent('create_app', {
|
||||
app_mode: appMode,
|
||||
description,
|
||||
})
|
||||
|
||||
notify({ type: 'success', message: t('app.newApp.appCreated') })
|
||||
onSuccess()
|
||||
onClose()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { getRedirection } from '@/utils/app-redirection'
|
|||
import cn from '@/utils/classnames'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { noop } from 'lodash-es'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type CreateFromDSLModalProps = {
|
||||
show: boolean
|
||||
|
|
@ -112,6 +113,13 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
|||
return
|
||||
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
|
||||
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)
|
||||
onSuccess()
|
||||
if (onClose)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
|||
import type { QueryParam } from './index'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { trackEvent } from '@/app/components/base/amplitude/utils'
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
const today = dayjs()
|
||||
|
|
@ -37,6 +38,9 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
|||
value={queryParams.status || 'all'}
|
||||
onSelect={(item) => {
|
||||
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' })}
|
||||
items={[{ value: 'all', name: 'All' },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default } from './AmplitudeProvider'
|
||||
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
|||
import { useDocLink } from '@/context/i18n'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
|
||||
export default function AppSelector() {
|
||||
const itemClassName = `
|
||||
|
|
@ -53,7 +54,7 @@ export default function AppSelector() {
|
|||
const { mutateAsync: logout } = useLogout()
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
resetUser()
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Toast from '@/app/components/base/toast'
|
|||
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
export default function CheckCode() {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
|
@ -44,6 +45,12 @@ export default function CheckCode() {
|
|||
setIsLoading(true)
|
||||
const ret = await emailLoginWithCode({ email, code, token, language })
|
||||
if (ret.result === 'success') {
|
||||
// Track login success event
|
||||
trackEvent('user_login_success', {
|
||||
method: 'email_code',
|
||||
is_invite: !!invite_token,
|
||||
})
|
||||
|
||||
if (invite_token) {
|
||||
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import I18NContext from '@/context/i18n'
|
|||
import { noop } from 'lodash-es'
|
||||
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
|
||||
import type { ResponseError } from '@/service/fetch'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
type MailAndPasswordAuthProps = {
|
||||
isInvite: boolean
|
||||
|
|
@ -63,6 +64,12 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis
|
|||
body: loginData,
|
||||
})
|
||||
if (res.result === 'success') {
|
||||
// Track login success event
|
||||
trackEvent('user_login_success', {
|
||||
method: 'email_password',
|
||||
is_invite: isInvite,
|
||||
})
|
||||
|
||||
if (isInvite) {
|
||||
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export default function CheckCode() {
|
|||
}
|
||||
setIsLoading(true)
|
||||
const res = await verifyCode({ email, code, token })
|
||||
console.log(res)
|
||||
if ((res as MailValidityResponse).is_valid) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('token', encodeURIComponent((res as MailValidityResponse).token))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Input from '@/app/components/base/input'
|
|||
import { validPassword } from '@/config'
|
||||
import type { MailRegisterResponse } from '@/service/use-common'
|
||||
import { useMailRegister } from '@/service/use-common'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
|
||||
const ChangePasswordForm = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -54,6 +55,11 @@ const ChangePasswordForm = () => {
|
|||
})
|
||||
const { result } = res as MailRegisterResponse
|
||||
if (result === 'success') {
|
||||
// Track registration success event
|
||||
trackEvent('user_registration_success', {
|
||||
method: 'email',
|
||||
})
|
||||
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { noop } from 'lodash-es'
|
|||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import { useGlobalPublicStore } from './global-public-context'
|
||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||
|
||||
export type AppContextValue = {
|
||||
userProfile: UserProfileResponse
|
||||
|
|
@ -159,6 +160,28 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
|||
}, [currentWorkspace?.id])
|
||||
// #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 (
|
||||
<AppContext.Provider value={{
|
||||
userProfile,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextRequest } 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) => {
|
||||
// prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@
|
|||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "^2.31.3",
|
||||
"@amplitude/plugin-session-replay-browser": "^1.23.6",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/react": "^0.26.28",
|
||||
"@formatjs/intl-localematcher": "^0.5.10",
|
||||
|
|
|
|||
3587
web/pnpm-lock.yaml
3587
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue