fix(web): defer Amplitude SDK loading until analytics consent (#42857)

This commit is contained in:
Stephen Zhou
2026-09-24 01:48:27 +00:00
committed by GitHub
parent 2f9fa672d0
commit 7feebe3405
8 changed files with 215 additions and 104 deletions
@@ -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])
@@ -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(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
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(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
unmount()
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
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(<AmplitudeProvider />)
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
mockConsent.value = 'denied'
rerender(<AmplitudeProvider />)
@@ -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(<AmplitudeProvider />)
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(<AmplitudeProvider />)
if (action === 'revoke') {
mockConsent.value = 'denied'
rerender(<AmplitudeProvider />)
} 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(<AmplitudeProvider />)
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as
| amplitude.Types.EnrichmentPlugin
| undefined
@@ -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)
@@ -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', () => {
+20 -65
View File
@@ -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<typeof import('./runtime').initializeAmplitudeSDK> | undefined
let initialization: Promise<void> | 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<string, string> = {
'': '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<void> => {
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)
}
@@ -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<string, string> = {
'': '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,
}
}
+16 -12
View File
@@ -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<string, unknown>,
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<string, amplitude.Types.ValidPropertyType>,
) => {
if (!canUseAmplitude()) return
export const setUserProperties = (properties: Record<string, Types.ValidPropertyType>) => {
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()
}
+30
View File
@@ -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