Skip to content

Commit b9d8013

Browse files
feat: add Creator Hub download funnel tracking (#619)
* feat: add creator hub download funnel tracking * fix: align creator hub funnel events with the shared download tracker --------- Co-authored-by: Braian Mellor <braianj@gmail.com>
1 parent d7a9113 commit b9d8013

7 files changed

Lines changed: 531 additions & 20 deletions

File tree

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { act, renderHook } from '@testing-library/react'
2+
import { DownloadPlace, SegmentEvent } from '../modules/segment'
3+
import { useCreatorHubDownload } from './useCreatorHubDownload'
4+
5+
const mockTrack = jest.fn()
6+
const mockTriggerFileDownload = jest.fn()
7+
8+
let mockAnonUserId: string | undefined = 'anon-xyz'
9+
let mockHasValidIdentity = false
10+
let mockUserAgent: [boolean, { os: { name: string }; cpu: { architecture: string } } | undefined] = [
11+
false,
12+
{ os: { name: 'macOS' }, cpu: { architecture: 'arm64' } }
13+
]
14+
let mockLinks: Record<string, Record<string, string>> | undefined
15+
let mockLinksLoading = false
16+
17+
jest.mock('@dcl/hooks', () => ({
18+
useAdvancedUserAgentData: () => mockUserAgent
19+
}))
20+
21+
jest.mock('./useDeferredTrack', () => ({
22+
useDeferredTrack: () => mockTrack
23+
}))
24+
25+
jest.mock('./useAnonUserId', () => ({
26+
ANON_USER_ID_PARAM: 'anon_user_id',
27+
useAnonUserId: () => mockAnonUserId
28+
}))
29+
30+
jest.mock('./useAuthIdentity', () => ({
31+
useAuthIdentity: () => ({ hasValidIdentity: mockHasValidIdentity })
32+
}))
33+
34+
jest.mock('./useLatestGithubRelease', () => ({
35+
Repo: { CREATOR_HUB: 'creator-hub' },
36+
useLatestGithubRelease: () => ({ links: mockLinks, loading: mockLinksLoading })
37+
}))
38+
39+
jest.mock('../modules/file', () => ({
40+
triggerFileDownload: (...args: unknown[]) => mockTriggerFileDownload(...args)
41+
}))
42+
43+
jest.mock('../modules/url', () => ({
44+
addQueryParamsToUrlString: (url: string, params: Record<string, string | undefined | null>) => {
45+
const u = new URL(url)
46+
Object.entries(params).forEach(([key, value]) => {
47+
if (value !== undefined && value !== null) u.searchParams.append(key, value)
48+
})
49+
return u.toString()
50+
},
51+
updateUrlWithLastValue: (url: string, key: string, value: string) => {
52+
const u = new URL(url)
53+
u.searchParams.delete(key)
54+
u.searchParams.append(key, value)
55+
return u.toString()
56+
}
57+
}))
58+
59+
const MAC_LINK = 'https://cdn.example.com/Decentraland-Creator-Hub-arm64.dmg'
60+
const WIN_LINK = 'https://cdn.example.com/Creator-Hub-Setup.exe'
61+
62+
const originalLocation = window.location
63+
64+
describe('useCreatorHubDownload', () => {
65+
beforeAll(() => {
66+
Object.defineProperty(window, 'location', {
67+
configurable: true,
68+
writable: true,
69+
value: { href: '', origin: 'http://localhost' }
70+
})
71+
})
72+
73+
afterAll(() => {
74+
Object.defineProperty(window, 'location', { configurable: true, writable: true, value: originalLocation })
75+
})
76+
77+
beforeEach(() => {
78+
jest.useFakeTimers()
79+
mockAnonUserId = 'anon-xyz'
80+
mockHasValidIdentity = false
81+
mockUserAgent = [false, { os: { name: 'macOS' }, cpu: { architecture: 'arm64' } }]
82+
mockLinks = { macOS: { arm64: MAC_LINK }, Windows: { amd64: WIN_LINK } }
83+
mockLinksLoading = false
84+
window.location.href = ''
85+
})
86+
87+
afterEach(() => {
88+
jest.runOnlyPendingTimers()
89+
jest.useRealTimers()
90+
jest.resetAllMocks()
91+
})
92+
93+
describe('when handleDownload is called with a valid option', () => {
94+
it('should fire download_started with the creator-hub-download-page place and trigger the file download', () => {
95+
const { result } = renderHook(() => useCreatorHubDownload())
96+
97+
act(() => {
98+
result.current.handleDownload(result.current.primaryOption!)
99+
})
100+
101+
expect(mockTrack).toHaveBeenCalledWith(
102+
SegmentEvent.DOWNLOAD_STARTED,
103+
expect.objectContaining({
104+
href: MAC_LINK,
105+
os: 'macOS',
106+
arch: 'arm64',
107+
place: DownloadPlace.CREATOR_HUB_DOWNLOAD_PAGE,
108+
109+
anon_user_id: 'anon-xyz',
110+
111+
auth_state: 'anonymous',
112+
revisit: 0,
113+
114+
started_at: expect.any(Number)
115+
})
116+
)
117+
expect(mockTriggerFileDownload).toHaveBeenCalledWith(MAC_LINK)
118+
})
119+
120+
it('should report auth_state authenticated when the visitor has a valid identity', () => {
121+
mockHasValidIdentity = true
122+
const { result } = renderHook(() => useCreatorHubDownload())
123+
124+
act(() => {
125+
result.current.handleDownload(result.current.primaryOption!)
126+
})
127+
128+
expect(mockTrack).toHaveBeenCalledWith(
129+
SegmentEvent.DOWNLOAD_STARTED,
130+
131+
expect.objectContaining({ auth_state: 'authenticated' })
132+
)
133+
})
134+
135+
it('should forward arch, os and anon_user_id to the success redirect url', () => {
136+
const { result } = renderHook(() => useCreatorHubDownload())
137+
138+
act(() => {
139+
result.current.handleDownload(result.current.primaryOption!)
140+
})
141+
act(() => {
142+
jest.advanceTimersByTime(3000)
143+
})
144+
145+
expect(window.location.href).toContain('/download/creator-hub-success')
146+
expect(window.location.href).toContain('os=macOS')
147+
expect(window.location.href).toContain('arch=arm64')
148+
expect(window.location.href).toContain('anon_user_id=anon-xyz')
149+
})
150+
})
151+
152+
describe('when anon_user_id is unavailable', () => {
153+
beforeEach(() => {
154+
mockAnonUserId = undefined
155+
})
156+
157+
it('should omit anon_user_id from the event payload and the redirect url', () => {
158+
const { result } = renderHook(() => useCreatorHubDownload())
159+
160+
act(() => {
161+
result.current.handleDownload(result.current.primaryOption!)
162+
})
163+
act(() => {
164+
jest.advanceTimersByTime(3000)
165+
})
166+
167+
const payload = mockTrack.mock.calls[0][1]
168+
expect(payload).not.toHaveProperty('anon_user_id')
169+
expect(window.location.href).not.toContain('anon_user_id')
170+
})
171+
})
172+
173+
describe('when the option has no download link', () => {
174+
it('should not track or trigger a download', () => {
175+
const { result } = renderHook(() => useCreatorHubDownload())
176+
177+
act(() => {
178+
result.current.handleDownload({ text: 'macOS', image: 'icon.svg' })
179+
})
180+
181+
expect(mockTrack).not.toHaveBeenCalled()
182+
expect(mockTriggerFileDownload).not.toHaveBeenCalled()
183+
})
184+
})
185+
186+
describe('when handleDownload is called twice with a pending redirect', () => {
187+
it('should clear the previous redirect timer and re-fire the funnel', () => {
188+
const { result } = renderHook(() => useCreatorHubDownload())
189+
190+
act(() => {
191+
result.current.handleDownload(result.current.primaryOption!)
192+
})
193+
act(() => {
194+
result.current.handleDownload(result.current.primaryOption!)
195+
})
196+
197+
expect(mockTrack).toHaveBeenCalledTimes(2)
198+
expect(mockTriggerFileDownload).toHaveBeenCalledTimes(2)
199+
})
200+
})
201+
202+
describe('when the visitor OS has no available download', () => {
203+
beforeEach(() => {
204+
mockUserAgent = [false, { os: { name: 'Linux' }, cpu: { architecture: 'amd64' } }]
205+
})
206+
207+
it('should expose no primary option', () => {
208+
const { result } = renderHook(() => useCreatorHubDownload())
209+
210+
expect(result.current.primaryOption).toBeNull()
211+
})
212+
})
213+
214+
describe('when user agent data is still loading', () => {
215+
beforeEach(() => {
216+
mockLinksLoading = true
217+
})
218+
219+
it('should report not ready', () => {
220+
const { result } = renderHook(() => useCreatorHubDownload())
221+
222+
expect(result.current.isReady).toBe(false)
223+
})
224+
})
225+
226+
describe('when the visitor is on Windows', () => {
227+
beforeEach(() => {
228+
mockUserAgent = [false, { os: { name: 'Windows' }, cpu: { architecture: 'amd64' } }]
229+
})
230+
231+
it('should expose macOS as a secondary option', () => {
232+
const { result } = renderHook(() => useCreatorHubDownload())
233+
234+
expect(result.current.primaryOption?.text).toBe('Windows')
235+
expect(result.current.secondaryOptions.map(option => option.text)).toContain('macOS')
236+
})
237+
})
238+
})

src/hooks/useCreatorHubDownload.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
22
import { useAdvancedUserAgentData } from '@dcl/hooks'
33
import appleLogo from '../images/apple-logo.svg'
44
import microsoftLogo from '../images/microsoft-logo.svg'
5+
import { createDownloadTracker, toAuthState } from '../modules/downloadTracking'
56
import { triggerFileDownload } from '../modules/file'
7+
import { DownloadPlace } from '../modules/segment'
68
import { addQueryParamsToUrlString, updateUrlWithLastValue } from '../modules/url'
79
import { OperativeSystem } from '../types/download.types'
810
import type { Architecture } from '../types/download.types'
11+
import { ANON_USER_ID_PARAM, useAnonUserId } from './useAnonUserId'
12+
import { useAuthIdentity } from './useAuthIdentity'
13+
import { useDeferredTrack } from './useDeferredTrack'
914
import { Repo, useLatestGithubRelease } from './useLatestGithubRelease'
1015

1116
const REDIRECT_PATH = '/download/creator-hub-success'
@@ -24,6 +29,9 @@ const imageByOs: Record<string, string> = {
2429
}
2530

2631
function useCreatorHubDownload() {
32+
const track = useDeferredTrack()
33+
const anonUserId = useAnonUserId()
34+
const { hasValidIdentity } = useAuthIdentity()
2735
const [isLoadingUserAgentData, userAgentData] = useAdvancedUserAgentData()
2836
const { links, loading: isLoadingLinks } = useLatestGithubRelease(Repo.CREATOR_HUB)
2937
const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -79,22 +87,43 @@ function useCreatorHubDownload() {
7987
})
8088
}, [userAgentData, links])
8189

82-
const handleDownload = useCallback((option: DownloadOption) => {
83-
if (!option.link) return
90+
const handleDownload = useCallback(
91+
(option: DownloadOption) => {
92+
if (!option.link) return
8493

85-
if (redirectTimerRef.current !== null) {
86-
clearTimeout(redirectTimerRef.current)
87-
redirectTimerRef.current = null
88-
}
89-
90-
triggerFileDownload(option.link)
94+
if (redirectTimerRef.current !== null) {
95+
clearTimeout(redirectTimerRef.current)
96+
redirectTimerRef.current = null
97+
}
9198

92-
const redirectUrl = updateUrlWithLastValue(new URL(REDIRECT_PATH, window.location.origin).toString(), 'os', option.text)
93-
const finalUrl = addQueryParamsToUrlString(redirectUrl, { arch: option.arch })
94-
redirectTimerRef.current = setTimeout(() => {
95-
window.location.href = finalUrl
96-
}, REDIRECT_DELAY_MS)
97-
}, [])
99+
const tracker = createDownloadTracker(track, {
100+
href: option.link,
101+
os: option.text as OperativeSystem,
102+
arch: option.arch ?? 'amd64',
103+
place: DownloadPlace.CREATOR_HUB_DOWNLOAD_PAGE,
104+
// eslint-disable-next-line @typescript-eslint/naming-convention
105+
anon_user_id: anonUserId,
106+
// eslint-disable-next-line @typescript-eslint/naming-convention
107+
auth_state: toAuthState(hasValidIdentity),
108+
// A download click is a one-shot intent; there is no per-attempt revisit
109+
// notion on this page. The funnel is joined to the success page via
110+
// anon_user_id, so revisit stays 0 to satisfy the shared schema.
111+
revisit: 0
112+
})
113+
tracker.started()
114+
triggerFileDownload(option.link)
115+
116+
const redirectUrl = updateUrlWithLastValue(new URL(REDIRECT_PATH, window.location.origin).toString(), 'os', option.text)
117+
// Forward anon_user_id so the success page's download_success can be joined
118+
// to this download_started for the same visitor (useAnonUserId reads it
119+
// back from the URL on the next page).
120+
const finalUrl = addQueryParamsToUrlString(redirectUrl, { arch: option.arch, [ANON_USER_ID_PARAM]: anonUserId })
121+
redirectTimerRef.current = setTimeout(() => {
122+
window.location.href = finalUrl
123+
}, REDIRECT_DELAY_MS)
124+
},
125+
[track, anonUserId, hasValidIdentity]
126+
)
98127

99128
return { isReady, primaryOption, secondaryOptions, handleDownload }
100129
}

src/modules/downloadTracking.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { DownloadPlace, SegmentEvent } from './segment'
2-
import type { DownloadTrackFn, DownloadTracker, DownloadTrackerContext } from './downloadTracking.types'
2+
import type { AuthState, DownloadTrackFn, DownloadTracker, DownloadTrackerContext } from './downloadTracking.types'
3+
4+
/**
5+
* Maps the localStorage identity presence flag to the `auth_state` dimension
6+
* shared by every `download_*` event. Extracted so the Explorer and Creator
7+
* Hub funnels derive it identically instead of repeating the ternary.
8+
*/
9+
const toAuthState = (hasValidIdentity: boolean): AuthState => (hasValidIdentity ? 'authenticated' : 'anonymous')
310

411
const buildBasePayload = (ctx: DownloadTrackerContext): Record<string, unknown> => {
512
// ctx.extra goes first so the core schema fields below take precedence on
@@ -77,4 +84,4 @@ function createDownloadTracker(track: DownloadTrackFn, ctx: DownloadTrackerConte
7784
}
7885
}
7986

80-
export { createDownloadTracker }
87+
export { createDownloadTracker, toAuthState }

src/modules/segment.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ enum DownloadPlace {
8888
PLAY_EXPERIMENTAL_WEB = 'play-experimental-web',
8989
DOWNLOAD_PAGE = 'download-page',
9090
DOWNLOAD_SUCCESS_FOOTER = 'download-success-footer',
91+
CREATOR_HUB_DOWNLOAD_PAGE = 'creator-hub-download-page',
92+
CREATOR_HUB_SUCCESS_PAGE = 'creator-hub-success-page',
9193
UNKNOWN = 'unknown'
9294
}
9395

src/pages/DownloadSuccess/DownloadSuccess.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import windowsDownloadsFolder from '../../images/download/windows_downloads_fold
1515
import windowsLaunchingDecentraland from '../../images/download/windows_launching_decentraland.webp'
1616
import windowsSetup from '../../images/download/windows_setup.webp'
1717
import microsoftLogo from '../../images/microsoft-logo.svg'
18-
import { createDownloadTracker } from '../../modules/downloadTracking'
18+
import { createDownloadTracker, toAuthState } from '../../modules/downloadTracking'
1919
import type { DownloadTracker } from '../../modules/downloadTracking.types'
2020
import { calculateDownloadUrl } from '../../modules/downloadWithIdentity'
2121
import { collectClientFingerprint } from '../../modules/fingerprint'
@@ -49,7 +49,7 @@ const DownloadSuccess = memo(() => {
4949
// on anon_user_id. Useful for breaking down the funnel by login state and
5050
// for catching regressions where authenticated users fall back to the
5151
// anonymous gateway path.
52-
const authState: 'authenticated' | 'anonymous' = hasValidIdentity ? 'authenticated' : 'anonymous'
52+
const authState = toAuthState(hasValidIdentity)
5353

5454
const [isDownloading, setIsDownloading] = useState(false)
5555
const [downloadError, setDownloadError] = useState<string | null>(null)

0 commit comments

Comments
 (0)