mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-20 15:20:15 +08:00
feat(amplitude): integrate AmplitudeProvider and refactor initialization logic
- Added AmplitudeProvider to the main layout for analytics tracking. - Removed redundant AmplitudeProvider instances from common layouts. - Introduced a new init module to handle Amplitude initialization and session replay plugin setup. - Updated tests to ensure proper initialization behavior and prevent multiple initializations.
This commit is contained in:
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { AppInitializer } from '@/app/components/app-initializer'
|
import { AppInitializer } from '@/app/components/app-initializer'
|
||||||
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
||||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
|
||||||
import GA, { GaType } from '@/app/components/base/ga'
|
import GA, { GaType } from '@/app/components/base/ga'
|
||||||
import Zendesk from '@/app/components/base/zendesk'
|
import Zendesk from '@/app/components/base/zendesk'
|
||||||
import { GotoAnything } from '@/app/components/goto-anything'
|
import { GotoAnything } from '@/app/components/goto-anything'
|
||||||
@@ -20,7 +19,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GA gaType={GaType.admin} />
|
<GA gaType={GaType.admin} />
|
||||||
<AmplitudeProvider />
|
|
||||||
<AppInitializer>
|
<AppInitializer>
|
||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<EventEmitterContextProvider>
|
<EventEmitterContextProvider>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { AppInitializer } from '@/app/components/app-initializer'
|
import { AppInitializer } from '@/app/components/app-initializer'
|
||||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
|
||||||
import GA, { GaType } from '@/app/components/base/ga'
|
import GA, { GaType } from '@/app/components/base/ga'
|
||||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||||
import { AppContextProvider } from '@/context/app-context-provider'
|
import { AppContextProvider } from '@/context/app-context-provider'
|
||||||
@@ -14,7 +13,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GA gaType={GaType.admin} />
|
<GA gaType={GaType.admin} />
|
||||||
<AmplitudeProvider />
|
|
||||||
<AppInitializer>
|
<AppInitializer>
|
||||||
<AppContextProvider>
|
<AppContextProvider>
|
||||||
<EventEmitterContextProvider>
|
<EventEmitterContextProvider>
|
||||||
|
|||||||
@@ -1,82 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import * as amplitude from '@amplitude/analytics-browser'
|
import type { AmplitudeInitializationOptions } from './init'
|
||||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
import { ensureAmplitudeInitialized } from './init'
|
||||||
|
|
||||||
export type IAmplitudeProps = {
|
export type IAmplitudeProps = AmplitudeInitializationOptions
|
||||||
sessionReplaySampleRate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
|
||||||
const getEnglishPageName = (pathname: string): string => {
|
|
||||||
// Remove leading slash and get the first segment
|
|
||||||
const segments = pathname.replace(/^\//, '').split('/')
|
|
||||||
const firstSegment = segments[0] || 'home'
|
|
||||||
|
|
||||||
const pageNameMap: Record<string, string> = {
|
|
||||||
'': 'Home',
|
|
||||||
'apps': 'Studio',
|
|
||||||
'datasets': 'Knowledge',
|
|
||||||
'explore': 'Explore',
|
|
||||||
'tools': 'Tools',
|
|
||||||
'account': 'Account',
|
|
||||||
'signin': 'Sign In',
|
|
||||||
'signup': 'Sign Up',
|
|
||||||
}
|
|
||||||
|
|
||||||
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrichment plugin to override page title with English name for page view events
|
|
||||||
const pageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
|
||||||
return {
|
|
||||||
name: 'page-name-enrichment',
|
|
||||||
type: 'enrichment',
|
|
||||||
setup: async () => undefined,
|
|
||||||
execute: async (event: amplitude.Types.Event) => {
|
|
||||||
// Only modify page view events
|
|
||||||
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
|
||||||
/* v8 ignore next @preserve */
|
|
||||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
|
||||||
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||||
sessionReplaySampleRate = 0.5,
|
sessionReplaySampleRate = 0.5,
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only enable in Saas edition with valid API key
|
ensureAmplitudeInitialized({
|
||||||
if (!isAmplitudeEnabled)
|
sessionReplaySampleRate,
|
||||||
return
|
|
||||||
|
|
||||||
// Initialize Amplitude
|
|
||||||
amplitude.init(AMPLITUDE_API_KEY, {
|
|
||||||
defaultTracking: {
|
|
||||||
sessions: true,
|
|
||||||
pageViews: true,
|
|
||||||
formInteractions: true,
|
|
||||||
fileDownloads: true,
|
|
||||||
attribution: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
}, [sessionReplaySampleRate])
|
||||||
// Add page name enrichment plugin to override page title with English name
|
|
||||||
amplitude.add(pageNameEnrichmentPlugin())
|
|
||||||
|
|
||||||
// Add Session Replay plugin
|
|
||||||
const sessionReplay = sessionReplayPlugin({
|
|
||||||
sampleRate: sessionReplaySampleRate,
|
|
||||||
})
|
|
||||||
amplitude.add(sessionReplay)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// This is a client component that renders nothing
|
// This is a client component that renders nothing
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AmplitudeProvider from '../AmplitudeProvider'
|
import AmplitudeProvider from '../AmplitudeProvider'
|
||||||
|
import { resetAmplitudeInitializationForTests } from '../init'
|
||||||
|
|
||||||
const mockConfig = vi.hoisted(() => ({
|
const mockConfig = vi.hoisted(() => ({
|
||||||
AMPLITUDE_API_KEY: 'test-api-key',
|
AMPLITUDE_API_KEY: 'test-api-key',
|
||||||
@@ -35,6 +36,7 @@ describe('AmplitudeProvider', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||||
mockConfig.IS_CLOUD_EDITION = true
|
mockConfig.IS_CLOUD_EDITION = true
|
||||||
|
resetAmplitudeInitializationForTests()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Component', () => {
|
describe('Component', () => {
|
||||||
@@ -46,6 +48,17 @@ describe('AmplitudeProvider', () => {
|
|||||||
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not re-initialize amplitude on remount', () => {
|
||||||
|
const { unmount } = render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||||
|
|
||||||
|
expect(amplitude.init).toHaveBeenCalledTimes(1)
|
||||||
|
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
|
||||||
|
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('does not initialize amplitude when disabled', () => {
|
it('does not initialize amplitude when disabled', () => {
|
||||||
mockConfig.AMPLITUDE_API_KEY = ''
|
mockConfig.AMPLITUDE_API_KEY = ''
|
||||||
render(<AmplitudeProvider />)
|
render(<AmplitudeProvider />)
|
||||||
|
|||||||
61
web/app/components/base/amplitude/__tests__/init.spec.ts
Normal file
61
web/app/components/base/amplitude/__tests__/init.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as amplitude from '@amplitude/analytics-browser'
|
||||||
|
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ensureAmplitudeInitialized, resetAmplitudeInitializationForTests } from '../init'
|
||||||
|
|
||||||
|
const mockConfig = vi.hoisted(() => ({
|
||||||
|
AMPLITUDE_API_KEY: 'test-api-key',
|
||||||
|
IS_CLOUD_EDITION: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/config', () => ({
|
||||||
|
get AMPLITUDE_API_KEY() {
|
||||||
|
return mockConfig.AMPLITUDE_API_KEY
|
||||||
|
},
|
||||||
|
get IS_CLOUD_EDITION() {
|
||||||
|
return mockConfig.IS_CLOUD_EDITION
|
||||||
|
},
|
||||||
|
get isAmplitudeEnabled() {
|
||||||
|
return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@amplitude/analytics-browser', () => ({
|
||||||
|
init: vi.fn(),
|
||||||
|
add: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
|
||||||
|
sessionReplayPlugin: vi.fn(() => ({ name: 'session-replay' })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('amplitude init helper', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||||
|
mockConfig.IS_CLOUD_EDITION = true
|
||||||
|
resetAmplitudeInitializationForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ensureAmplitudeInitialized', () => {
|
||||||
|
it('should initialize amplitude only once across repeated calls', () => {
|
||||||
|
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 })
|
||||||
|
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 })
|
||||||
|
|
||||||
|
expect(amplitude.init).toHaveBeenCalledTimes(1)
|
||||||
|
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
|
||||||
|
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
|
||||||
|
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip initialization when amplitude is disabled', () => {
|
||||||
|
mockConfig.AMPLITUDE_API_KEY = ''
|
||||||
|
|
||||||
|
ensureAmplitudeInitialized()
|
||||||
|
|
||||||
|
expect(amplitude.init).not.toHaveBeenCalled()
|
||||||
|
expect(sessionReplayPlugin).not.toHaveBeenCalled()
|
||||||
|
expect(amplitude.add).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
82
web/app/components/base/amplitude/init.ts
Normal file
82
web/app/components/base/amplitude/init.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import * as amplitude from '@amplitude/analytics-browser'
|
||||||
|
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||||
|
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
||||||
|
|
||||||
|
export type AmplitudeInitializationOptions = {
|
||||||
|
sessionReplaySampleRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAmplitudeInitialized = false
|
||||||
|
|
||||||
|
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||||
|
const getEnglishPageName = (pathname: string): string => {
|
||||||
|
// Remove leading slash and get the first segment
|
||||||
|
const segments = pathname.replace(/^\//, '').split('/')
|
||||||
|
const firstSegment = segments[0] || 'home'
|
||||||
|
|
||||||
|
const pageNameMap: Record<string, string> = {
|
||||||
|
'': 'Home',
|
||||||
|
'apps': 'Studio',
|
||||||
|
'datasets': 'Knowledge',
|
||||||
|
'explore': 'Explore',
|
||||||
|
'tools': 'Tools',
|
||||||
|
'account': 'Account',
|
||||||
|
'signin': 'Sign In',
|
||||||
|
'signup': 'Sign Up',
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrichment plugin to override page title with English name for page view events
|
||||||
|
const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||||
|
return {
|
||||||
|
name: 'page-name-enrichment',
|
||||||
|
type: 'enrichment',
|
||||||
|
setup: async () => undefined,
|
||||||
|
execute: async (event: amplitude.Types.Event) => {
|
||||||
|
// Only modify page view events
|
||||||
|
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
||||||
|
/* v8 ignore next @preserve */
|
||||||
|
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||||
|
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureAmplitudeInitialized = ({
|
||||||
|
sessionReplaySampleRate = 0.5,
|
||||||
|
}: AmplitudeInitializationOptions = {}) => {
|
||||||
|
if (!isAmplitudeEnabled || isAmplitudeInitialized)
|
||||||
|
return
|
||||||
|
|
||||||
|
isAmplitudeInitialized = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
amplitude.init(AMPLITUDE_API_KEY, {
|
||||||
|
defaultTracking: {
|
||||||
|
sessions: true,
|
||||||
|
pageViews: true,
|
||||||
|
formInteractions: true,
|
||||||
|
fileDownloads: true,
|
||||||
|
attribution: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
amplitude.add(createPageNameEnrichmentPlugin())
|
||||||
|
amplitude.add(sessionReplayPlugin({
|
||||||
|
sampleRate: sessionReplaySampleRate,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
isAmplitudeInitialized = false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only used by unit tests to reset module-scoped initialization state.
|
||||||
|
export const resetAmplitudeInitializationForTests = () => {
|
||||||
|
isAmplitudeInitialized = false
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
|||||||
import { Provider as JotaiProvider } from 'jotai/react'
|
import { Provider as JotaiProvider } from 'jotai/react'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||||
|
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||||
import { getDatasetMap } from '@/env'
|
import { getDatasetMap } from '@/env'
|
||||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||||
@@ -56,6 +57,7 @@ const LocaleLayout = async ({
|
|||||||
{...datasetMap}
|
{...datasetMap}
|
||||||
>
|
>
|
||||||
<div className="isolate h-full">
|
<div className="isolate h-full">
|
||||||
|
<AmplitudeProvider />
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="data-theme"
|
attribute="data-theme"
|
||||||
|
|||||||
Reference in New Issue
Block a user