diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx
index 2467f35b7b..699d2a4348 100644
--- a/web/app/(commonLayout)/layout.tsx
+++ b/web/app/(commonLayout)/layout.tsx
@@ -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 (
<>
-
diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx
index 8fdbd8a238..f116cd00f9 100644
--- a/web/app/account/(commonLayout)/layout.tsx
+++ b/web/app/account/(commonLayout)/layout.tsx
@@ -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 (
<>
-
diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx
index 00af15e24d..346cfaa7c4 100644
--- a/web/app/components/base/amplitude/AmplitudeProvider.tsx
+++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx
@@ -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 = {
- '': '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 = ({
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
diff --git a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
index 5835634eb7..a0080a0c0c 100644
--- a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
+++ b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
@@ -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()
+
+ unmount()
+ render()
+
+ 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()
diff --git a/web/app/components/base/amplitude/__tests__/init.spec.ts b/web/app/components/base/amplitude/__tests__/init.spec.ts
new file mode 100644
index 0000000000..25a5410148
--- /dev/null
+++ b/web/app/components/base/amplitude/__tests__/init.spec.ts
@@ -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()
+ })
+ })
+})
diff --git a/web/app/components/base/amplitude/init.ts b/web/app/components/base/amplitude/init.ts
new file mode 100644
index 0000000000..209b7dfac0
--- /dev/null
+++ b/web/app/components/base/amplitude/init.ts
@@ -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 = {
+ '': '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
+}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 009f2ca584..9fb2b8dae2 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -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}
>