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