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 { AppInitializer } from '@/app/components/app-initializer'
|
||||
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 Zendesk from '@/app/components/base/zendesk'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
@@ -20,7 +19,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
@@ -14,7 +13,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@@ -1,82 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import type { AmplitudeInitializationOptions } from './init'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config'
|
||||
import { ensureAmplitudeInitialized } from './init'
|
||||
|
||||
export type IAmplitudeProps = {
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
export type IAmplitudeProps = AmplitudeInitializationOptions
|
||||
|
||||
const AmplitudeProvider: FC<IAmplitudeProps> = ({
|
||||
sessionReplaySampleRate = 0.5,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// Only enable in Saas edition with valid API key
|
||||
if (!isAmplitudeEnabled)
|
||||
return
|
||||
|
||||
// Initialize Amplitude
|
||||
amplitude.init(AMPLITUDE_API_KEY, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
attribution: true,
|
||||
},
|
||||
ensureAmplitudeInitialized({
|
||||
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)
|
||||
}, [])
|
||||
}, [sessionReplaySampleRate])
|
||||
|
||||
// This is a client component that renders nothing
|
||||
return null
|
||||
|
||||
@@ -3,6 +3,7 @@ import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AmplitudeProvider from '../AmplitudeProvider'
|
||||
import { resetAmplitudeInitializationForTests } from '../init'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
AMPLITUDE_API_KEY: 'test-api-key',
|
||||
@@ -35,6 +36,7 @@ describe('AmplitudeProvider', () => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
resetAmplitudeInitializationForTests()
|
||||
})
|
||||
|
||||
describe('Component', () => {
|
||||
@@ -46,6 +48,17 @@ describe('AmplitudeProvider', () => {
|
||||
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', () => {
|
||||
mockConfig.AMPLITUDE_API_KEY = ''
|
||||
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 { ThemeProvider } from 'next-themes'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { getDatasetMap } from '@/env'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
@@ -56,6 +57,7 @@ const LocaleLayout = async ({
|
||||
{...datasetMap}
|
||||
>
|
||||
<div className="isolate h-full">
|
||||
<AmplitudeProvider />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
|
||||
Reference in New Issue
Block a user