mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
fix(web): attach Amplitude user ID before firing registration event (#37091)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9da4d167fa
commit
0db9714eb6
@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { useSearchParams } from '@/next/navigation'
|
import { useSearchParams } from '@/next/navigation'
|
||||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||||
|
|
||||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({
|
||||||
mockSendGAEvent: vi.fn(),
|
mockSendGAEvent: vi.fn(),
|
||||||
mockTrackEvent: vi.fn(),
|
mockRememberRegistrationSuccess: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/gtag', () => ({
|
vi.mock('@/utils/gtag', () => ({
|
||||||
@ -17,8 +17,8 @@ vi.mock('@/next/navigation', () => ({
|
|||||||
useSearchParams: vi.fn(),
|
useSearchParams: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../base/amplitude', () => ({
|
vi.mock('../base/amplitude/registration-tracking', () => ({
|
||||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||||
@ -48,10 +48,9 @@ describe('OAuthRegistrationAnalytics', () => {
|
|||||||
render(<OAuthRegistrationAnalytics />)
|
render(<OAuthRegistrationAnalytics />)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||||
method: 'oauth',
|
method: 'oauth',
|
||||||
utm_source: 'linkedin',
|
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||||
slug: 'agent-launch',
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||||
@ -73,8 +72,9 @@ describe('OAuthRegistrationAnalytics', () => {
|
|||||||
render(<OAuthRegistrationAnalytics />)
|
render(<OAuthRegistrationAnalytics />)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||||
method: 'oauth',
|
method: 'oauth',
|
||||||
|
utmInfo: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||||
@ -87,7 +87,7 @@ describe('OAuthRegistrationAnalytics', () => {
|
|||||||
it('should do nothing without the oauth registration query flag', () => {
|
it('should do nothing without the oauth registration query flag', () => {
|
||||||
render(<OAuthRegistrationAnalytics />)
|
render(<OAuthRegistrationAnalytics />)
|
||||||
|
|
||||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ describe('OAuthRegistrationAnalytics', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||||
})
|
})
|
||||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
flushRegistrationSuccess,
|
||||||
|
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||||
|
rememberRegistrationSuccess,
|
||||||
|
} from '../registration-tracking'
|
||||||
|
|
||||||
|
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('../utils', () => ({
|
||||||
|
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('registration tracking', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
window.sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Captures the registration event for a later flush instead of firing it right away.
|
||||||
|
describe('rememberRegistrationSuccess', () => {
|
||||||
|
it('should store the base event and not track immediately when there is no utm info', () => {
|
||||||
|
rememberRegistrationSuccess({ method: 'email' })
|
||||||
|
|
||||||
|
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||||
|
eventName: 'user_registration_success',
|
||||||
|
properties: { method: 'email' },
|
||||||
|
})
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store the utm event and merge utm info into properties when utm info is present', () => {
|
||||||
|
rememberRegistrationSuccess({
|
||||||
|
method: 'oauth',
|
||||||
|
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||||
|
eventName: 'user_registration_success_with_utm',
|
||||||
|
properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should swallow errors when writing to sessionStorage fails', () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
sessionStorage: {
|
||||||
|
getItem: vi.fn(() => null),
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('quota exceeded')
|
||||||
|
},
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Replays the remembered event exactly once, after the user ID has been attached.
|
||||||
|
describe('flushRegistrationSuccess', () => {
|
||||||
|
it('should track the remembered event and clear it from storage', () => {
|
||||||
|
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } })
|
||||||
|
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||||
|
method: 'email',
|
||||||
|
utm_source: 'blog',
|
||||||
|
})
|
||||||
|
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should do nothing when there is no pending event', () => {
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fire the event at most once across repeated flushes', () => {
|
||||||
|
rememberRegistrationSuccess({ method: 'oauth' })
|
||||||
|
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear malformed pending data without tracking', () => {
|
||||||
|
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json')
|
||||||
|
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the pending entry without tracking when it has no event name', () => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||||
|
JSON.stringify({ properties: { method: 'email' } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop without tracking when reading from sessionStorage throws', () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
sessionStorage: {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error('read failed')
|
||||||
|
},
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should still track when clearing the pending entry fails', () => {
|
||||||
|
const pending = { eventName: 'user_registration_success', properties: { method: 'email' } }
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
sessionStorage: {
|
||||||
|
getItem: () => JSON.stringify(pending),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error('remove failed')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
flushRegistrationSuccess()
|
||||||
|
|
||||||
|
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { method: 'email' })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both producers and the consumer must degrade gracefully when sessionStorage is
|
||||||
|
// missing (SSR) or blocked (privacy mode / disabled storage).
|
||||||
|
describe('when sessionStorage is unavailable', () => {
|
||||||
|
it('should no-op without throwing when window is undefined', () => {
|
||||||
|
vi.stubGlobal('window', undefined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||||
|
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should no-op without throwing when accessing sessionStorage throws', () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
get sessionStorage() {
|
||||||
|
throw new Error('storage disabled')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow()
|
||||||
|
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||||
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
88
web/app/components/base/amplitude/registration-tracking.ts
Normal file
88
web/app/components/base/amplitude/registration-tracking.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { trackEvent } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for a registration success event that is waiting to be sent to
|
||||||
|
* Amplitude until a user ID has been attached.
|
||||||
|
*/
|
||||||
|
export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event'
|
||||||
|
|
||||||
|
type RegistrationMethod = 'email' | 'oauth'
|
||||||
|
|
||||||
|
type PendingRegistrationSuccessEvent = {
|
||||||
|
eventName: string
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSessionStorage = (): Storage | null => {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return null
|
||||||
|
return window.sessionStorage
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember a registration success event so it can be sent to Amplitude *after* the
|
||||||
|
* user ID is attached (see `flushRegistrationSuccess`).
|
||||||
|
*
|
||||||
|
* Amplitude attributes events to whatever identity is active when `track` runs. At
|
||||||
|
* registration time the client does not yet know the user ID, so firing the event
|
||||||
|
* immediately records it under an anonymous profile. We persist the event here and
|
||||||
|
* replay it once `setUserId` runs in the app context provider after the redirect.
|
||||||
|
*/
|
||||||
|
export const rememberRegistrationSuccess = (
|
||||||
|
{ method, utmInfo }: { method: RegistrationMethod, utmInfo?: Record<string, unknown> | null },
|
||||||
|
) => {
|
||||||
|
const storage = getSessionStorage()
|
||||||
|
if (!storage)
|
||||||
|
return
|
||||||
|
|
||||||
|
const pending: PendingRegistrationSuccessEvent = {
|
||||||
|
eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success',
|
||||||
|
properties: { method, ...utmInfo },
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending))
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a previously remembered registration success event to Amplitude.
|
||||||
|
*
|
||||||
|
* MUST be called after `setUserId` so the event lands on the identified user profile.
|
||||||
|
* No-op when nothing is pending. The pending entry is removed before tracking so the
|
||||||
|
* event fires at most once even if this runs multiple times.
|
||||||
|
*/
|
||||||
|
export const flushRegistrationSuccess = () => {
|
||||||
|
const storage = getSessionStorage()
|
||||||
|
if (!storage)
|
||||||
|
return
|
||||||
|
|
||||||
|
let raw: string | null = null
|
||||||
|
try {
|
||||||
|
raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent
|
||||||
|
if (pending?.eventName)
|
||||||
|
trackEvent(pending.eventName, pending.properties)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import Cookies from 'js-cookie'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useSearchParams } from '@/next/navigation'
|
import { useSearchParams } from '@/next/navigation'
|
||||||
import { sendGAEvent } from '@/utils/gtag'
|
import { sendGAEvent } from '@/utils/gtag'
|
||||||
import { trackEvent } from './base/amplitude'
|
import { rememberRegistrationSuccess } from './base/amplitude/registration-tracking'
|
||||||
|
|
||||||
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
|
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
|
||||||
|
|
||||||
@ -48,10 +48,10 @@ export function OAuthRegistrationAnalytics() {
|
|||||||
|
|
||||||
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
|
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
|
||||||
|
|
||||||
trackEvent(eventName, {
|
// Defer the Amplitude event until the user ID is attached. It is flushed in
|
||||||
method: 'oauth',
|
// AppContextProvider after setUserId runs. Firing it here would record it under an
|
||||||
...utmInfo,
|
// anonymous Amplitude profile (no user ID set yet).
|
||||||
})
|
rememberRegistrationSuccess({ method: 'oauth', utmInfo })
|
||||||
|
|
||||||
sendGAEvent(eventName, {
|
sendGAEvent(eventName, {
|
||||||
method: 'oauth',
|
method: 'oauth',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ReactElement } from 'react'
|
|||||||
import type { MockedFunction } from 'vitest'
|
import type { MockedFunction } from 'vitest'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import Cookies from 'js-cookie'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { useLocale } from '@/context/i18n'
|
import { useLocale } from '@/context/i18n'
|
||||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||||
@ -9,6 +10,11 @@ import { useMailRegister } from '@/service/use-common'
|
|||||||
import { getBrowserTimezone } from '@/utils/timezone'
|
import { getBrowserTimezone } from '@/utils/timezone'
|
||||||
import ChangePasswordForm from '../page'
|
import ChangePasswordForm from '../page'
|
||||||
|
|
||||||
|
const { mockRememberRegistrationSuccess, mockSendGAEvent } = vi.hoisted(() => ({
|
||||||
|
mockRememberRegistrationSuccess: vi.fn(),
|
||||||
|
mockSendGAEvent: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/context/i18n', () => ({
|
vi.mock('@/context/i18n', () => ({
|
||||||
useLocale: vi.fn(),
|
useLocale: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@ -27,11 +33,11 @@ vi.mock('@/utils/timezone', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/gtag', () => ({
|
vi.mock('@/utils/gtag', () => ({
|
||||||
sendGAEvent: vi.fn(),
|
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/amplitude', () => ({
|
vi.mock('@/app/components/base/amplitude/registration-tracking', () => ({
|
||||||
trackEvent: vi.fn(),
|
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/create-app-tracking', () => ({
|
vi.mock('@/utils/create-app-tracking', () => ({
|
||||||
@ -64,6 +70,7 @@ const renderWithQueryClient = (ui: ReactElement) => {
|
|||||||
describe('Signup Set Password Page', () => {
|
describe('Signup Set Password Page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
Cookies.remove('utm_info')
|
||||||
mockUseLocale.mockReturnValue('zh-Hans')
|
mockUseLocale.mockReturnValue('zh-Hans')
|
||||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('token=register-token') as unknown as ReturnType<typeof useSearchParams>)
|
mockUseSearchParams.mockReturnValue(new URLSearchParams('token=register-token') as unknown as ReturnType<typeof useSearchParams>)
|
||||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
||||||
@ -98,4 +105,56 @@ describe('Signup Set Password Page', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// On successful registration the Amplitude event is deferred (remembered) so it can
|
||||||
|
// fire after the user ID is attached, while the GA event still fires immediately.
|
||||||
|
describe('Registration success tracking', () => {
|
||||||
|
const fillAndSubmit = () => {
|
||||||
|
fireEvent.change(screen.getByLabelText('common.account.newPassword'), {
|
||||||
|
target: { value: 'ValidPass123!' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('common.account.confirmPassword'), {
|
||||||
|
target: { value: 'ValidPass123!' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'login.changePasswordBtn' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should defer the amplitude event and fire GA immediately when registration succeeds', async () => {
|
||||||
|
mockRegister.mockResolvedValue({ result: 'success', data: {} })
|
||||||
|
|
||||||
|
renderWithQueryClient(<ChangePasswordForm />)
|
||||||
|
fillAndSubmit()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||||
|
method: 'email',
|
||||||
|
utmInfo: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||||
|
method: 'email',
|
||||||
|
})
|
||||||
|
expect(mockReplace).toHaveBeenCalledWith('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remember the utm event and clear the utm cookie when a utm_info cookie is present', async () => {
|
||||||
|
Cookies.set('utm_info', JSON.stringify({ utm_source: 'twitter' }))
|
||||||
|
mockRegister.mockResolvedValue({ result: 'success', data: {} })
|
||||||
|
|
||||||
|
renderWithQueryClient(<ChangePasswordForm />)
|
||||||
|
fillAndSubmit()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||||
|
method: 'email',
|
||||||
|
utmInfo: { utm_source: 'twitter' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||||
|
method: 'email',
|
||||||
|
utm_source: 'twitter',
|
||||||
|
})
|
||||||
|
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { trackEvent } from '@/app/components/base/amplitude'
|
import { rememberRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import { validPassword } from '@/config'
|
import { validPassword } from '@/config'
|
||||||
import { useLocale } from '@/context/i18n'
|
import { useLocale } from '@/context/i18n'
|
||||||
@ -78,10 +78,10 @@ const ChangePasswordForm = () => {
|
|||||||
if (result === 'success') {
|
if (result === 'success') {
|
||||||
const utmInfo = parseUtmInfo()
|
const utmInfo = parseUtmInfo()
|
||||||
rememberCreateAppExternalAttribution({ utmInfo })
|
rememberCreateAppExternalAttribution({ utmInfo })
|
||||||
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
// Defer the Amplitude event until the user ID is attached. It is flushed in
|
||||||
method: 'email',
|
// AppContextProvider after setUserId runs once the redirect lands on /apps.
|
||||||
...utmInfo,
|
// Firing it here would record it under an anonymous Amplitude profile.
|
||||||
})
|
rememberRegistrationSuccess({ method: 'email', utmInfo })
|
||||||
|
|
||||||
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
||||||
method: 'email',
|
method: 'email',
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/comm
|
|||||||
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
|
||||||
|
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
|
||||||
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
|
||||||
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
|
||||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||||
@ -160,6 +161,11 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUserProperties(properties)
|
setUserProperties(properties)
|
||||||
|
|
||||||
|
// The user ID is now attached, so replay any registration success event captured
|
||||||
|
// at signup time. This makes it land on the identified Amplitude profile instead
|
||||||
|
// of an anonymous one (no-op when nothing was deferred).
|
||||||
|
flushRegistrationSuccess()
|
||||||
}
|
}
|
||||||
}, [userProfile, currentWorkspace])
|
}, [userProfile, currentWorkspace])
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user