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:
CodingOnStar
2026-04-20 14:03:58 +08:00
parent 8f070f2190
commit b6a0062164
7 changed files with 164 additions and 71 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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 />)

View 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()
})
})
})

View 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
}

View File

@@ -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"