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