Skip to content

Commit 7e25bf9

Browse files
feat: enrich segment download events with client fingerprint signals (#448)
1 parent dfb0a71 commit 7e25bf9

4 files changed

Lines changed: 241 additions & 25 deletions

File tree

src/modules/fingerprint.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Asserts the snake_case analytics field names produced by the module.
2+
/* eslint-disable @typescript-eslint/naming-convention */
3+
import { collectClientFingerprint } from './fingerprint'
4+
5+
describe('collectClientFingerprint', () => {
6+
let originalScreen: PropertyDescriptor | undefined
7+
let originalDpr: PropertyDescriptor | undefined
8+
let originalNavigatorLanguage: PropertyDescriptor | undefined
9+
let originalHardwareConcurrency: PropertyDescriptor | undefined
10+
let originalPlatform: PropertyDescriptor | undefined
11+
12+
beforeEach(() => {
13+
originalScreen = Object.getOwnPropertyDescriptor(window, 'screen')
14+
originalDpr = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio')
15+
originalNavigatorLanguage = Object.getOwnPropertyDescriptor(window.navigator, 'language')
16+
originalHardwareConcurrency = Object.getOwnPropertyDescriptor(window.navigator, 'hardwareConcurrency')
17+
originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, 'platform')
18+
})
19+
20+
afterEach(() => {
21+
if (originalScreen) Object.defineProperty(window, 'screen', originalScreen)
22+
if (originalDpr) Object.defineProperty(window, 'devicePixelRatio', originalDpr)
23+
if (originalNavigatorLanguage) Object.defineProperty(window.navigator, 'language', originalNavigatorLanguage)
24+
if (originalHardwareConcurrency) Object.defineProperty(window.navigator, 'hardwareConcurrency', originalHardwareConcurrency)
25+
if (originalPlatform) Object.defineProperty(window.navigator, 'platform', originalPlatform)
26+
})
27+
28+
describe('when called in a normal browser environment', () => {
29+
beforeEach(() => {
30+
Object.defineProperty(window, 'screen', {
31+
configurable: true,
32+
value: { width: 1920, height: 1080, colorDepth: 24 }
33+
})
34+
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 })
35+
Object.defineProperty(window.navigator, 'language', { configurable: true, value: 'en-US' })
36+
Object.defineProperty(window.navigator, 'hardwareConcurrency', { configurable: true, value: 8 })
37+
Object.defineProperty(window.navigator, 'platform', { configurable: true, value: 'MacIntel' })
38+
})
39+
40+
it('should populate every field from the browser primitives', () => {
41+
const fp = collectClientFingerprint()
42+
43+
expect(fp).not.toBeNull()
44+
expect(fp).toMatchObject({
45+
screen_width: 1920,
46+
screen_height: 1080,
47+
device_pixel_ratio: 2,
48+
color_depth: 24,
49+
hardware_concurrency: 8,
50+
language: 'en-US',
51+
platform: 'MacIntel'
52+
})
53+
})
54+
55+
it('should include the IANA timezone when Intl exposes it', () => {
56+
const fp = collectClientFingerprint()
57+
// jsdom resolves to a real timezone (host's). We just assert it's a string.
58+
expect(typeof fp?.timezone).toBe('string')
59+
expect(fp?.timezone?.length ?? 0).toBeGreaterThan(0)
60+
})
61+
62+
it('should report the timezone offset as a number', () => {
63+
const fp = collectClientFingerprint()
64+
expect(typeof fp?.timezone_offset_minutes).toBe('number')
65+
expect(Number.isFinite(fp?.timezone_offset_minutes)).toBe(true)
66+
})
67+
})
68+
69+
describe('when individual browser fields are missing', () => {
70+
it('should fall back to safe defaults rather than crashing', () => {
71+
Object.defineProperty(window, 'screen', { configurable: true, value: undefined })
72+
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: undefined })
73+
Object.defineProperty(window.navigator, 'hardwareConcurrency', { configurable: true, value: undefined })
74+
Object.defineProperty(window.navigator, 'language', { configurable: true, value: undefined })
75+
Object.defineProperty(window.navigator, 'platform', { configurable: true, value: undefined })
76+
77+
const fp = collectClientFingerprint()
78+
79+
expect(fp).toMatchObject({
80+
screen_width: 0,
81+
screen_height: 0,
82+
device_pixel_ratio: 1,
83+
color_depth: 0,
84+
hardware_concurrency: 0,
85+
language: null,
86+
platform: null
87+
})
88+
})
89+
})
90+
})

src/modules/fingerprint.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Field names are deliberately snake_case to match the analytics event
2+
// contract used end-to-end (Segment events, data warehouse columns, the
3+
// Rust launcher's emitter). The `naming-convention` rule is camelCase by
4+
// default in this repo; opt out for this module so we don't keep adding
5+
// per-line eslint-disable comments.
6+
/* eslint-disable @typescript-eslint/naming-convention */
7+
8+
/**
9+
* Stable signals about the user's environment that the launcher (running
10+
* as a desktop app on the same machine) can also read from the OS, so a
11+
* data warehouse can join landing-site events with launcher install events
12+
* without an embedded user-level identifier.
13+
*
14+
* We don't try to be unique per user — we collect a handful of features
15+
* that, combined with the user's IP and event timestamp, narrow down the
16+
* matching window to a manageable set of candidates. The actual scoring
17+
* lives in the data team's Segment Functions / SQL join.
18+
*
19+
* Field names use snake_case to match the convention used by the launcher
20+
* Segment client (Rust / `analytics_queue.db`), so the join columns line
21+
* up without renaming.
22+
*/
23+
interface ClientFingerprint {
24+
/** Primary monitor width in CSS pixels. */
25+
screen_width: number
26+
/** Primary monitor height in CSS pixels. */
27+
screen_height: number
28+
/** Device pixel ratio (e.g. `2` on Retina, `1` on standard). */
29+
device_pixel_ratio: number
30+
/** Bits per pixel for the primary display. */
31+
color_depth: number
32+
/** Logical CPU cores reported by the browser, capped at 8 by some browsers. */
33+
hardware_concurrency: number
34+
/** IANA timezone name when available (e.g. `America/Argentina/Buenos_Aires`). */
35+
timezone: string | null
36+
/** Minutes offset from UTC at the moment of the call (negative for east of UTC). */
37+
timezone_offset_minutes: number
38+
/** Primary `navigator.language` (e.g. `en-US`). */
39+
language: string | null
40+
/**
41+
* `navigator.platform` value. Deprecated and increasingly browser-locked,
42+
* but still a useful low-cardinality signal (`Win32`, `MacIntel`, `Linux x86_64`).
43+
* Fall back to `null` if unavailable.
44+
*/
45+
platform: string | null
46+
}
47+
48+
/**
49+
* Returns a snapshot of the current browser/device fingerprint signals.
50+
* Safe to call from anywhere in the page lifecycle — no I/O, no async.
51+
*
52+
* Returns `null` only if `window`/`navigator` are unavailable (SSR / unit
53+
* tests); callers should be tolerant of that case rather than crashing.
54+
*/
55+
function collectClientFingerprint(): ClientFingerprint | null {
56+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
57+
return null
58+
}
59+
60+
let timezone: string | null = null
61+
try {
62+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null
63+
} catch {
64+
// Old browsers / locked-down environments may throw — falls back to null.
65+
}
66+
67+
return {
68+
screen_width: window.screen?.width ?? 0,
69+
screen_height: window.screen?.height ?? 0,
70+
device_pixel_ratio: window.devicePixelRatio ?? 1,
71+
color_depth: window.screen?.colorDepth ?? 0,
72+
hardware_concurrency: navigator.hardwareConcurrency ?? 0,
73+
timezone,
74+
timezone_offset_minutes: new Date().getTimezoneOffset(),
75+
language: navigator.language ?? null,
76+
platform: navigator.platform ?? null
77+
}
78+
}
79+
80+
export { collectClientFingerprint }
81+
export type { ClientFingerprint }

src/pages/DownloadSuccess/DownloadSuccess.spec.tsx

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -93,26 +93,53 @@ describe('when DownloadSuccess mounts with os, place, and a successful url resol
9393
render(<DownloadSuccess />)
9494

9595
await waitFor(() => {
96-
expect(mockTrack).toHaveBeenCalledWith('download_started', {
97-
place: 'landing-hero',
98-
href: 'https://cdn.decentraland.org/launcher/Install-Decentraland.exe',
99-
100-
auth_state: 'anonymous'
101-
})
96+
expect(mockTrack).toHaveBeenCalledWith(
97+
'download_started',
98+
expect.objectContaining({
99+
place: 'landing-hero',
100+
href: 'https://cdn.decentraland.org/launcher/Install-Decentraland.exe',
101+
auth_state: 'anonymous'
102+
})
103+
)
102104
})
103105
})
104106

105107
it('should fire download_success with place, the resolved href, and filename', async () => {
106108
render(<DownloadSuccess />)
107109

108110
await waitFor(() => {
109-
expect(mockTrack).toHaveBeenCalledWith('download_success', {
110-
place: 'landing-hero',
111-
href: 'https://cdn.decentraland.org/launcher/signed/Install-Decentraland.exe?sig=abc',
112-
filename: 'Install-Decentraland.exe',
111+
expect(mockTrack).toHaveBeenCalledWith(
112+
'download_success',
113+
expect.objectContaining({
114+
place: 'landing-hero',
115+
href: 'https://cdn.decentraland.org/launcher/signed/Install-Decentraland.exe?sig=abc',
116+
filename: 'Install-Decentraland.exe',
117+
auth_state: 'anonymous'
118+
})
119+
)
120+
})
121+
})
113122

114-
auth_state: 'anonymous'
115-
})
123+
it('should include the client fingerprint fields on download_success for server-side attribution', async () => {
124+
render(<DownloadSuccess />)
125+
126+
await waitFor(() => {
127+
expect(mockTrack).toHaveBeenCalledWith(
128+
'download_success',
129+
expect.objectContaining({
130+
screen_width: expect.any(Number),
131+
132+
screen_height: expect.any(Number),
133+
134+
device_pixel_ratio: expect.any(Number),
135+
136+
color_depth: expect.any(Number),
137+
138+
hardware_concurrency: expect.any(Number),
139+
140+
timezone_offset_minutes: expect.any(Number)
141+
})
142+
)
116143
})
117144
})
118145

@@ -238,12 +265,14 @@ describe('when DownloadSuccess mounts and the url resolution rejects', () => {
238265
render(<DownloadSuccess />)
239266

240267
await waitFor(() => {
241-
expect(mockTrack).toHaveBeenCalledWith('download_failed', {
242-
place: 'download-page',
243-
href: 'https://cdn.decentraland.org/launcher/Install-Decentraland.exe',
244-
245-
auth_state: 'anonymous'
246-
})
268+
expect(mockTrack).toHaveBeenCalledWith(
269+
'download_failed',
270+
expect.objectContaining({
271+
place: 'download-page',
272+
href: 'https://cdn.decentraland.org/launcher/Install-Decentraland.exe',
273+
auth_state: 'anonymous'
274+
})
275+
)
247276
})
248277
})
249278
})

src/pages/DownloadSuccess/DownloadSuccess.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import windowsLaunchingDecentraland from '../../images/download/windows_launchin
1515
import windowsSetup from '../../images/download/windows_setup.webp'
1616
import microsoftLogo from '../../images/microsoft-logo.svg'
1717
import { calculateDownloadUrl } from '../../modules/downloadWithIdentity'
18+
import { collectClientFingerprint } from '../../modules/fingerprint'
1819
import { DownloadPlace, SectionViewedTrack, SegmentEvent, resolveDownloadPlace } from '../../modules/segment'
1920
import { streamOrFallback } from '../../modules/streamOrFallback'
2021
import { FALLBACK_CDN_RELEASE_LINKS, addQueryParamsToUrlString } from '../../modules/url'
@@ -221,23 +222,30 @@ const DownloadSuccess = memo(() => {
221222
// "the file finished downloading" rather than "the user clicked".
222223
// If the transfer fails the .catch() below fires DOWNLOAD_FAILED
223224
// instead, so success/failed are mutually exclusive.
225+
//
226+
// The `fingerprint` payload below is what the data team's server-side
227+
// join uses to match this download with the launcher's first-run
228+
// event from the same machine — see the IP + heuristic fingerprint
229+
// attribution doc.
230+
const fingerprint = collectClientFingerprint()
224231
await waitForAnalytics()
225232
if (signal.aborted || !isInitializedRef.current) return
226233
// eslint-disable-next-line @typescript-eslint/naming-convention
227-
trackRef.current(SegmentEvent.DOWNLOAD_STARTED, { place, href: osLink, auth_state: authStateRef.current })
234+
trackRef.current(SegmentEvent.DOWNLOAD_STARTED, { place, href: osLink, auth_state: authStateRef.current, ...fingerprint })
228235
// eslint-disable-next-line @typescript-eslint/naming-convention
229-
trackRef.current(SegmentEvent.DOWNLOAD_SUCCESS, { place, href: url, filename, auth_state: authStateRef.current })
236+
trackRef.current(SegmentEvent.DOWNLOAD_SUCCESS, { place, href: url, filename, auth_state: authStateRef.current, ...fingerprint })
230237
}
231238

232239
startDownload()
233240
.catch(async error => {
234241
if (signal.aborted) return
235242
console.error('Download error:', error)
236243
setDownloadError(error instanceof Error ? error.message : 'Download failed')
244+
const fingerprint = collectClientFingerprint()
237245
await waitForAnalytics()
238246
if (signal.aborted || !isInitializedRef.current) return
239247
// eslint-disable-next-line @typescript-eslint/naming-convention
240-
trackRef.current(SegmentEvent.DOWNLOAD_FAILED, { place, href: osLink, auth_state: authStateRef.current })
248+
trackRef.current(SegmentEvent.DOWNLOAD_FAILED, { place, href: osLink, auth_state: authStateRef.current, ...fingerprint })
241249
})
242250
.finally(() => {
243251
if (!signal.aborted) {
@@ -267,9 +275,10 @@ const DownloadSuccess = memo(() => {
267275
const footerPlace = DownloadPlace.DOWNLOAD_SUCCESS_FOOTER
268276
// Re-download from the footer link is its own funnel event so analytics
269277
// can distinguish it from the auto-download that fires on page mount.
278+
const fingerprint = collectClientFingerprint()
270279
if (isInitializedRef.current) {
271280
// eslint-disable-next-line @typescript-eslint/naming-convention
272-
trackRef.current(SegmentEvent.DOWNLOAD_STARTED, { place: footerPlace, href: osLink, auth_state: authState })
281+
trackRef.current(SegmentEvent.DOWNLOAD_STARTED, { place: footerPlace, href: osLink, auth_state: authState, ...fingerprint })
273282
}
274283

275284
try {
@@ -297,16 +306,23 @@ const DownloadSuccess = memo(() => {
297306
// DOWNLOAD_SUCCESS only fires after streamOrFallback has resolved,
298307
// i.e. the bytes have effectively landed (Windows) or the
299308
// fallback hold elapsed (macOS / Windows fetch failure).
300-
// eslint-disable-next-line @typescript-eslint/naming-convention
301-
trackRef.current(SegmentEvent.DOWNLOAD_SUCCESS, { place: footerPlace, href: downloadUrl, filename, auth_state: authState })
309+
310+
trackRef.current(SegmentEvent.DOWNLOAD_SUCCESS, {
311+
place: footerPlace,
312+
href: downloadUrl,
313+
filename,
314+
// eslint-disable-next-line @typescript-eslint/naming-convention
315+
auth_state: authState,
316+
...fingerprint
317+
})
302318
}
303319
} catch (error) {
304320
if (signal.aborted) return
305321
console.error('Download error:', error)
306322
setDownloadError(error instanceof Error ? error.message : 'Download failed')
307323
if (isInitializedRef.current) {
308324
// eslint-disable-next-line @typescript-eslint/naming-convention
309-
trackRef.current(SegmentEvent.DOWNLOAD_FAILED, { place: footerPlace, href: osLink, auth_state: authState })
325+
trackRef.current(SegmentEvent.DOWNLOAD_FAILED, { place: footerPlace, href: osLink, auth_state: authState, ...fingerprint })
310326
}
311327
} finally {
312328
downloadingRef.current = false

0 commit comments

Comments
 (0)