diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx
index 2a9807663f6..20bd6230583 100644
--- a/web/app/components/base/amplitude/AmplitudeProvider.tsx
+++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx
@@ -21,10 +21,10 @@ export function AmplitudeProvider({
return
}
- ensureAmplitudeInitialized({
- sessionReplaySampleRate,
- })
setAmplitudeOptOut(false)
+ void ensureAmplitudeInitialized({ sessionReplaySampleRate }).catch((error) => {
+ console.error('Amplitude initialization failed:', error)
+ })
return () => setAmplitudeOptOut(true)
}, [active, consent, sessionReplaySampleRate])
diff --git a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
index c7f36edb78c..0e9b75dcf1b 100644
--- a/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
+++ b/web/app/components/base/amplitude/__tests__/AmplitudeProvider.spec.tsx
@@ -1,6 +1,6 @@
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
-import { render } from '@testing-library/react'
+import { act, render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
const mockConfig = vi.hoisted(() => ({
@@ -22,6 +22,12 @@ vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
add: vi.fn(),
setOptOut: vi.fn(),
+ track: vi.fn(),
+ flush: vi.fn(),
+ setUserId: vi.fn(),
+ Identify: vi.fn(),
+ identify: vi.fn(),
+ reset: vi.fn(),
}))
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
@@ -42,8 +48,9 @@ describe('AmplitudeProvider', () => {
})
describe('Component', () => {
- it('initializes amplitude when enabled', () => {
+ it('initializes amplitude when enabled', async () => {
render()
+ await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
expect(amplitude.init).toHaveBeenCalledWith('test-api-key', expect.any(Object))
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
@@ -51,11 +58,12 @@ describe('AmplitudeProvider', () => {
expect(amplitude.setOptOut).toHaveBeenCalledWith(false)
})
- it('does not re-initialize amplitude on remount', () => {
+ it('does not re-initialize amplitude on remount', async () => {
const { unmount } = render()
unmount()
render()
+ await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
expect(amplitude.init).toHaveBeenCalledTimes(1)
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
@@ -83,8 +91,9 @@ describe('AmplitudeProvider', () => {
},
)
- it('opts out on revoke and resumes without reinitializing', () => {
+ it('opts out on revoke and resumes without reinitializing', async () => {
const { rerender } = render()
+ await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
mockConsent.value = 'denied'
rerender()
@@ -100,16 +109,37 @@ describe('AmplitudeProvider', () => {
expect(amplitude.add).toHaveBeenCalledTimes(2)
})
- it('opts out when the analytics layout runtime unmounts', () => {
+ it('opts out when the analytics layout runtime unmounts', async () => {
const { unmount } = render()
+ await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
unmount()
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
})
+ it.each(['revoke', 'unmount'] as const)(
+ 'does not start tracking when %s happens during SDK loading',
+ async (action) => {
+ const { rerender, unmount } = render()
+ if (action === 'revoke') {
+ mockConsent.value = 'denied'
+ rerender()
+ } else {
+ unmount()
+ }
+
+ await act(async () => {
+ await vi.dynamicImportSettled()
+ })
+ expect(amplitude.init).not.toHaveBeenCalled()
+ expect(sessionReplayPlugin).not.toHaveBeenCalled()
+ },
+ )
+
it('pageNameEnrichmentPlugin logic works as expected', async () => {
render()
+ await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as
| amplitude.Types.EnrichmentPlugin
| undefined
diff --git a/web/app/components/base/amplitude/__tests__/init.spec.ts b/web/app/components/base/amplitude/__tests__/init.spec.ts
index 879497059e8..9374932ba3b 100644
--- a/web/app/components/base/amplitude/__tests__/init.spec.ts
+++ b/web/app/components/base/amplitude/__tests__/init.spec.ts
@@ -18,6 +18,12 @@ vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
add: vi.fn(),
setOptOut: vi.fn(),
+ track: vi.fn(),
+ flush: vi.fn(),
+ setUserId: vi.fn(),
+ Identify: vi.fn(),
+ identify: vi.fn(),
+ reset: vi.fn(),
}))
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
@@ -29,13 +35,17 @@ describe('amplitude init helper', () => {
vi.resetModules()
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
- ;({ ensureAmplitudeInitialized } = await import('../init'))
+ const init = await import('../init')
+ ensureAmplitudeInitialized = init.ensureAmplitudeInitialized
+ init.setAmplitudeOptOut(false)
})
describe('ensureAmplitudeInitialized', () => {
- it('should initialize amplitude only once across repeated calls', () => {
- ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 })
- ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 })
+ it('should initialize amplitude only once across concurrent calls', async () => {
+ await Promise.all([
+ ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 }),
+ ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 }),
+ ])
expect(amplitude.init).toHaveBeenCalledTimes(1)
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
@@ -47,7 +57,7 @@ describe('amplitude init helper', () => {
const { getIsAmplitudeInitialized } = await import('../init')
expect(getIsAmplitudeInitialized()).toBe(false)
- ensureAmplitudeInitialized()
+ await ensureAmplitudeInitialized()
expect(getIsAmplitudeInitialized()).toBe(true)
})
@@ -57,16 +67,30 @@ describe('amplitude init helper', () => {
const listener = vi.fn()
const unsubscribe = subscribeAmplitudeInitialization(listener)
- ensureAmplitudeInitialized()
+ await ensureAmplitudeInitialized()
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
- it('should skip initialization when amplitude is disabled', () => {
+ it('allows a later initialization attempt after SDK setup fails', async () => {
+ const { getIsAmplitudeInitialized } = await import('../init')
+ vi.mocked(amplitude.init).mockImplementationOnce(() => {
+ throw new Error('SDK setup failed')
+ })
+
+ await expect(ensureAmplitudeInitialized()).rejects.toThrow('SDK setup failed')
+ expect(getIsAmplitudeInitialized()).toBe(false)
+
+ await ensureAmplitudeInitialized()
+ expect(getIsAmplitudeInitialized()).toBe(true)
+ expect(amplitude.init).toHaveBeenCalledTimes(2)
+ })
+
+ it('should skip initialization when amplitude is disabled', async () => {
mockConfig.AMPLITUDE_API_KEY = ''
- ensureAmplitudeInitialized()
+ await ensureAmplitudeInitialized()
expect(amplitude.init).not.toHaveBeenCalled()
expect(sessionReplayPlugin).not.toHaveBeenCalled()
@@ -81,7 +105,8 @@ describe('amplitude init helper', () => {
setAmplitudeOptOut(true)
expect(amplitude.setOptOut).not.toHaveBeenCalled()
- ensureAmplitudeInitialized()
+ setAmplitudeOptOut(false)
+ await ensureAmplitudeInitialized()
setAmplitudeOptOut(true)
expect(amplitude.setOptOut).toHaveBeenCalledWith(true)
diff --git a/web/app/components/base/amplitude/__tests__/utils.spec.ts b/web/app/components/base/amplitude/__tests__/utils.spec.ts
index aa030937e2a..4c62795debe 100644
--- a/web/app/components/base/amplitude/__tests__/utils.spec.ts
+++ b/web/app/components/base/amplitude/__tests__/utils.spec.ts
@@ -28,16 +28,17 @@ vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
}))
vi.mock('../init', () => ({
- getIsAmplitudeInitialized: () => mockState.initialized,
-}))
-
-vi.mock('@amplitude/analytics-browser', () => ({
- track: (...args: unknown[]) => mockTrack(...args),
- flush: (...args: unknown[]) => mockFlush(...args),
- setUserId: (...args: unknown[]) => mockSetUserId(...args),
- identify: (...args: unknown[]) => mockIdentify(...args),
- reset: (...args: unknown[]) => mockReset(...args),
- Identify: MockIdentify,
+ getAmplitudeClient: () =>
+ mockState.initialized
+ ? {
+ track: mockTrack,
+ flush: mockFlush,
+ setUserId: mockSetUserId,
+ identify: mockIdentify,
+ reset: mockReset,
+ Identify: MockIdentify,
+ }
+ : undefined,
}))
describe('amplitude utils', () => {
diff --git a/web/app/components/base/amplitude/init.ts b/web/app/components/base/amplitude/init.ts
index 5d33a5cfc35..70bd8f52afd 100644
--- a/web/app/components/base/amplitude/init.ts
+++ b/web/app/components/base/amplitude/init.ts
@@ -1,5 +1,3 @@
-import * as amplitude from '@amplitude/analytics-browser'
-import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { AMPLITUDE_API_KEY } from '@/config'
export type AmplitudeInitializationOptions = {
@@ -7,6 +5,11 @@ export type AmplitudeInitializationOptions = {
}
let isAmplitudeInitialized = false
+let amplitude: ReturnType | undefined
+let initialization: Promise | undefined
+let shouldOptOut = true
+
+export const getAmplitudeClient = () => (isAmplitudeInitialized ? amplitude : undefined)
const initializationListeners = new Set<() => void>()
export const getIsAmplitudeInitialized = () => isAmplitudeInitialized
@@ -20,77 +23,29 @@ const notifyAmplitudeInitialized = () => {
initializationListeners.forEach((listener) => listener())
}
-// 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'
+async function initializeAmplitude(sessionReplaySampleRate: number) {
+ const { initializeAmplitudeSDK } = await import('./runtime')
- const pageNameMap: Record = {
- '': 'Home',
- apps: 'Studio',
- agents: 'Agents',
- datasets: 'Knowledge',
- explore: 'Explore',
- tools: 'Tools',
- account: 'Account',
- signin: 'Sign In',
- signup: 'Sign Up',
- }
+ // Consent or the owning layout may have changed while the chunk was loading.
+ if (shouldOptOut) return
- 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
- },
- }
+ amplitude = initializeAmplitudeSDK(AMPLITUDE_API_KEY, sessionReplaySampleRate)
+ isAmplitudeInitialized = true
+ notifyAmplitudeInitialized()
}
export const ensureAmplitudeInitialized = ({
sessionReplaySampleRate = 0.5,
-}: AmplitudeInitializationOptions = {}) => {
- if (!AMPLITUDE_API_KEY || isAmplitudeInitialized) return
+}: AmplitudeInitializationOptions = {}): Promise => {
+ if (!AMPLITUDE_API_KEY || isAmplitudeInitialized || shouldOptOut) return Promise.resolve()
- 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,
- }),
- )
- notifyAmplitudeInitialized()
- } catch (error) {
- isAmplitudeInitialized = false
- throw error
- }
+ initialization ??= initializeAmplitude(sessionReplaySampleRate).finally(() => {
+ initialization = undefined
+ })
+ return initialization
}
export const setAmplitudeOptOut = (optOut: boolean) => {
- if (!AMPLITUDE_API_KEY || !isAmplitudeInitialized) return
- amplitude.setOptOut(optOut)
+ shouldOptOut = optOut
+ getAmplitudeClient()?.setOptOut(optOut)
}
diff --git a/web/app/components/base/amplitude/runtime.ts b/web/app/components/base/amplitude/runtime.ts
new file mode 100644
index 00000000000..f91131d44db
--- /dev/null
+++ b/web/app/components/base/amplitude/runtime.ts
@@ -0,0 +1,66 @@
+import type { Types } from '@amplitude/analytics-browser'
+import * as amplitude from '@amplitude/analytics-browser'
+import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
+
+// 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',
+ agents: 'Agents',
+ 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 = (): Types.EnrichmentPlugin => {
+ return {
+ name: 'page-name-enrichment',
+ type: 'enrichment',
+ setup: async () => undefined,
+ execute: async (event: 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 function initializeAmplitudeSDK(apiKey: string, sessionReplaySampleRate: number) {
+ amplitude.init(apiKey, {
+ defaultTracking: {
+ sessions: true,
+ pageViews: true,
+ formInteractions: true,
+ fileDownloads: true,
+ attribution: true,
+ },
+ })
+ amplitude.add(createPageNameEnrichmentPlugin())
+ amplitude.add(sessionReplayPlugin({ sampleRate: sessionReplaySampleRate }))
+ amplitude.setOptOut(false)
+ return {
+ track: amplitude.track,
+ flush: amplitude.flush,
+ setUserId: amplitude.setUserId,
+ Identify: amplitude.Identify,
+ identify: amplitude.identify,
+ reset: amplitude.reset,
+ setOptOut: amplitude.setOptOut,
+ }
+}
diff --git a/web/app/components/base/amplitude/utils.ts b/web/app/components/base/amplitude/utils.ts
index bb6021d0ad1..405443a11d9 100644
--- a/web/app/components/base/amplitude/utils.ts
+++ b/web/app/components/base/amplitude/utils.ts
@@ -1,8 +1,9 @@
-import * as amplitude from '@amplitude/analytics-browser'
+import type { Types } from '@amplitude/analytics-browser'
import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
-import { getIsAmplitudeInitialized } from './init'
+import { getAmplitudeClient } from './init'
-const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
+const getConsentedClient = () =>
+ getAnalyticsConsent() === 'granted' ? getAmplitudeClient() : undefined
/**
* Track custom event
@@ -12,15 +13,17 @@ const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplit
export const trackEvent = (
eventName: string,
eventProperties?: Record,
- eventOptions?: amplitude.Types.EventOptions,
+ eventOptions?: Types.EventOptions,
) => {
- if (!canUseAmplitude()) return
+ const amplitude = getConsentedClient()
+ if (!amplitude) return
if (eventOptions) return amplitude.track(eventName, eventProperties, eventOptions)
return amplitude.track(eventName, eventProperties)
}
export const flushEvents = () => {
- if (!canUseAmplitude()) return
+ const amplitude = getConsentedClient()
+ if (!amplitude) return
return amplitude.flush()
}
@@ -29,7 +32,8 @@ export const flushEvents = () => {
* @param userId User ID
*/
export const setUserId = (userId: string) => {
- if (!canUseAmplitude()) return
+ const amplitude = getConsentedClient()
+ if (!amplitude) return
amplitude.setUserId(userId)
}
@@ -37,10 +41,9 @@ export const setUserId = (userId: string) => {
* Set user properties
* @param properties User properties
*/
-export const setUserProperties = (
- properties: Record,
-) => {
- if (!canUseAmplitude()) return
+export const setUserProperties = (properties: Record) => {
+ const amplitude = getConsentedClient()
+ if (!amplitude) return
const identifyEvent = new amplitude.Identify()
Object.entries(properties).forEach(([key, value]) => {
identifyEvent.set(key, value)
@@ -52,6 +55,7 @@ export const setUserProperties = (
* Reset user (e.g., when user logs out)
*/
export const resetUser = () => {
- if (!canUseAmplitude()) return
+ const amplitude = getConsentedClient()
+ if (!amplitude) return
amplitude.reset()
}
diff --git a/web/docs/bundle-size.md b/web/docs/bundle-size.md
index 2fc04f61b88..f72cd297356 100644
--- a/web/docs/bundle-size.md
+++ b/web/docs/bundle-size.md
@@ -69,4 +69,34 @@ node web/scripts/bundle-analysis.ts collect web "$(git rev-parse HEAD)" /tmp/cur
node web/scripts/bundle-analysis.ts compare /tmp/base.json /tmp/current.json /tmp/report.md
```
+## Next.js / Turbopack analysis
+
+The Web `analyze` script uses Next.js's built-in Turbopack analyzer:
+
+```sh
+pnpm --dir web analyze --output
+```
+
+Save `web/.next/diagnostics/analyze` before changing code, then collect another
+report with the same compiler version and environment. Omit `--output` to open
+the interactive analyzer and inspect route-specific client/server import chains.
+These reports are separate from the Vinext snapshots above.
+
+If analysis panics inside `next-api/src/nft.rs`, move
+`web/.next/cache/turbopack` outside `.next` and retry before changing production
+configuration. A stale filesystem cache caused this failure locally with
+Next.js 16.3.6; rebuilding the cache let the unchanged configuration complete.
+
+Check import chains before adding configuration. Next.js already optimizes
+imports from libraries such as `ahooks` and Heroicons. `serverExternalPackages`
+affects server bundling; it does not remove dependencies from browser bundles.
+
+A lazy component can still have eager dependencies if shared utilities import
+its SDK statically. Console analytics keeps its initialization state and event
+helpers lightweight, loading Amplitude and session replay only after analytics
+is enabled and consent is granted. Keep SDK imports in shared helpers type-only
+and use the initialized client for events. When changing this boundary, verify
+revocation and unmount during loading, concurrent initialization, and failure
+recovery as well as entry static dependencies and total emitted bytes.
+
[Rolldown analyzer documentation]: https://rolldown.rs/builtin-plugins/bundle-analyzer