Skip to content

Commit 6cba535

Browse files
Introduce Client Hints API (#864)
1 parent fb6811c commit 6cba535

File tree

14 files changed

+338
-66
lines changed

14 files changed

+338
-66
lines changed

.changeset/sixty-drinks-raise.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@segment/analytics-next': minor
3+
'@segment/analytics-core': minor
4+
---
5+
6+
Add Client Hints API support

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"size-limit": [
4444
{
4545
"path": "dist/umd/index.js",
46-
"limit": "28.1 KB"
46+
"limit": "28.5 KB"
4747
}
4848
],
4949
"dependencies": {

packages/browser/src/browser/__tests__/integration.test.ts

Lines changed: 95 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { getCDN, setGlobalCDNUrl } from '../../lib/parse-cdn'
1818
import { clearAjsBrowserStorage } from '../../test-helpers/browser-storage'
1919
import { parseFetchCall } from '../../test-helpers/fetch-parse'
2020
import { ActionDestination } from '../../plugins/remote-loader'
21+
import {
22+
highEntropyTestData,
23+
lowEntropyTestData,
24+
} from '../../lib/client-hints/__tests__/index.test'
25+
import { UADataValues } from '../../lib/client-hints/interfaces'
2126

2227
let fetchCalls: ReturnType<typeof parseFetchCall>[] = []
2328

@@ -207,76 +212,112 @@ describe('Initialization', () => {
207212
})
208213
})
209214

210-
it('calls page if initialpageview is set', async () => {
211-
jest.mock('../../core/analytics')
212-
const mockPage = jest.fn().mockImplementation(() => Promise.resolve())
213-
Analytics.prototype.page = mockPage
214-
215-
await AnalyticsBrowser.load({ writeKey }, { initialPageview: true })
215+
describe('Load options', () => {
216+
it('gets high entropy client hints if set', async () => {
217+
;(window.navigator as any).userAgentData = {
218+
...lowEntropyTestData,
219+
getHighEntropyValues: jest
220+
.fn()
221+
.mockImplementation((hints: string[]): Promise<UADataValues> => {
222+
let result = {}
223+
Object.entries(highEntropyTestData).forEach(([k, v]) => {
224+
if (hints.includes(k)) {
225+
result = {
226+
...result,
227+
[k]: v,
228+
}
229+
}
230+
})
231+
return Promise.resolve({
232+
...lowEntropyTestData,
233+
...result,
234+
})
235+
}),
236+
toJSON: jest.fn(() => lowEntropyTestData),
237+
}
216238

217-
expect(mockPage).toHaveBeenCalled()
218-
})
239+
const [ajs] = await AnalyticsBrowser.load(
240+
{ writeKey },
241+
{ highEntropyValuesClientHints: ['architecture'] }
242+
)
219243

220-
it('does not call page if initialpageview is not set', async () => {
221-
jest.mock('../../core/analytics')
222-
const mockPage = jest.fn()
223-
Analytics.prototype.page = mockPage
224-
await AnalyticsBrowser.load({ writeKey }, { initialPageview: false })
225-
expect(mockPage).not.toHaveBeenCalled()
226-
})
244+
const evt = await ajs.track('foo')
245+
expect(evt.event.context?.userAgentData).toEqual({
246+
...lowEntropyTestData,
247+
architecture: 'x86',
248+
})
249+
})
250+
it('calls page if initialpageview is set', async () => {
251+
jest.mock('../../core/analytics')
252+
const mockPage = jest.fn().mockImplementation(() => Promise.resolve())
253+
Analytics.prototype.page = mockPage
227254

228-
it('does not use a persisted queue when disableClientPersistence is true', async () => {
229-
const [ajs] = await AnalyticsBrowser.load(
230-
{
231-
writeKey,
232-
},
233-
{
234-
disableClientPersistence: true,
235-
}
236-
)
255+
await AnalyticsBrowser.load({ writeKey }, { initialPageview: true })
237256

238-
expect(ajs.queue.queue instanceof PriorityQueue).toBe(true)
239-
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(false)
240-
})
257+
expect(mockPage).toHaveBeenCalled()
258+
})
241259

242-
it('uses a persisted queue by default', async () => {
243-
const [ajs] = await AnalyticsBrowser.load({
244-
writeKey,
260+
it('does not call page if initialpageview is not set', async () => {
261+
jest.mock('../../core/analytics')
262+
const mockPage = jest.fn()
263+
Analytics.prototype.page = mockPage
264+
await AnalyticsBrowser.load({ writeKey }, { initialPageview: false })
265+
expect(mockPage).not.toHaveBeenCalled()
245266
})
246267

247-
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(true)
248-
})
268+
it('does not use a persisted queue when disableClientPersistence is true', async () => {
269+
const [ajs] = await AnalyticsBrowser.load(
270+
{
271+
writeKey,
272+
},
273+
{
274+
disableClientPersistence: true,
275+
}
276+
)
249277

250-
it('disables identity persistance when disableClientPersistence is true', async () => {
251-
const [ajs] = await AnalyticsBrowser.load(
252-
{
278+
expect(ajs.queue.queue instanceof PriorityQueue).toBe(true)
279+
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(false)
280+
})
281+
282+
it('uses a persisted queue by default', async () => {
283+
const [ajs] = await AnalyticsBrowser.load({
253284
writeKey,
254-
},
255-
{
256-
disableClientPersistence: true,
257-
}
258-
)
285+
})
259286

260-
expect(ajs.user().options.persist).toBe(false)
261-
expect(ajs.group().options.persist).toBe(false)
262-
})
287+
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(true)
288+
})
263289

264-
it('fetch remote source settings by default', async () => {
265-
await AnalyticsBrowser.load({
266-
writeKey,
290+
it('disables identity persistance when disableClientPersistence is true', async () => {
291+
const [ajs] = await AnalyticsBrowser.load(
292+
{
293+
writeKey,
294+
},
295+
{
296+
disableClientPersistence: true,
297+
}
298+
)
299+
300+
expect(ajs.user().options.persist).toBe(false)
301+
expect(ajs.group().options.persist).toBe(false)
267302
})
268303

269-
expect(fetchCalls.length).toBeGreaterThan(0)
270-
expect(fetchCalls[0].url).toMatch(/\/settings$/)
271-
})
304+
it('fetch remote source settings by default', async () => {
305+
await AnalyticsBrowser.load({
306+
writeKey,
307+
})
272308

273-
it('does not fetch source settings if cdnSettings is set', async () => {
274-
await AnalyticsBrowser.load({
275-
writeKey,
276-
cdnSettings: { integrations: {} },
309+
expect(fetchCalls.length).toBeGreaterThan(0)
310+
expect(fetchCalls[0].url).toMatch(/\/settings$/)
277311
})
278312

279-
expect(fetchCalls.length).toBe(0)
313+
it('does not fetch source settings if cdnSettings is set', async () => {
314+
await AnalyticsBrowser.load({
315+
writeKey,
316+
cdnSettings: { integrations: {} },
317+
})
318+
319+
expect(fetchCalls.length).toBe(0)
320+
})
280321
})
281322

282323
describe('options.integrations permutations', () => {

packages/browser/src/browser/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ async function registerPlugins(
227227

228228
if (!shouldIgnoreSegmentio) {
229229
toRegister.push(
230-
segmentio(
230+
await segmentio(
231231
analytics,
232232
mergedSettings['Segment.io'] as SegmentioSettings,
233233
legacySettings.integrations

packages/browser/src/core/analytics/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { version } from '../../generated/version'
4848
import { PriorityQueue } from '../../lib/priority-queue'
4949
import { getGlobal } from '../../lib/get-global'
5050
import { AnalyticsClassic, AnalyticsCore } from './interfaces'
51+
import { HighEntropyHint } from '../../lib/client-hints/interfaces'
5152

5253
const deprecationWarning =
5354
'This is being deprecated and will be not be available in future releases of Analytics JS'
@@ -106,6 +107,10 @@ export interface InitOptions {
106107
aid?: RegExp
107108
uid?: RegExp
108109
}
110+
/**
111+
* Array of high entropy Client Hints to request. These may be rejected by the user agent - only required hints should be requested.
112+
*/
113+
highEntropyValuesClientHints?: HighEntropyHint[]
109114
}
110115

111116
/* analytics-classic stubs */
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { clientHints } from '..'
2+
import { UADataValues, UALowEntropyJSON } from '../interfaces'
3+
4+
export const lowEntropyTestData: UALowEntropyJSON = {
5+
brands: [
6+
{
7+
brand: 'Google Chrome',
8+
version: '113',
9+
},
10+
{
11+
brand: 'Chromium',
12+
version: '113',
13+
},
14+
{
15+
brand: 'Not-A.Brand',
16+
version: '24',
17+
},
18+
],
19+
mobile: false,
20+
platform: 'macOS',
21+
}
22+
23+
export const highEntropyTestData: UADataValues = {
24+
architecture: 'x86',
25+
bitness: '64',
26+
}
27+
28+
describe('Client Hints API', () => {
29+
beforeEach(() => {
30+
;(window.navigator as any).userAgentData = {
31+
...lowEntropyTestData,
32+
getHighEntropyValues: jest
33+
.fn()
34+
.mockImplementation((hints: string[]): Promise<UADataValues> => {
35+
let result = {}
36+
Object.entries(highEntropyTestData).forEach(([k, v]) => {
37+
if (hints.includes(k)) {
38+
result = {
39+
...result,
40+
[k]: v,
41+
}
42+
}
43+
})
44+
return Promise.resolve({
45+
...lowEntropyTestData,
46+
...result,
47+
})
48+
}),
49+
toJSON: jest.fn(() => {
50+
return lowEntropyTestData
51+
}),
52+
}
53+
})
54+
55+
it('uses API when available', async () => {
56+
let userAgentData = await clientHints()
57+
expect(userAgentData).toEqual(lowEntropyTestData)
58+
;(window.navigator as any).userAgentData = undefined
59+
userAgentData = await clientHints()
60+
expect(userAgentData).toBe(undefined)
61+
})
62+
63+
it('always gets low entropy hints', async () => {
64+
const userAgentData = await clientHints()
65+
expect(userAgentData).toEqual(lowEntropyTestData)
66+
})
67+
68+
it('gets low entropy hints when client rejects high entropy promise', async () => {
69+
;(window.navigator as any).userAgentData = {
70+
...lowEntropyTestData,
71+
getHighEntropyValues: jest.fn(() => Promise.reject()),
72+
toJSON: jest.fn(() => lowEntropyTestData),
73+
}
74+
75+
const userAgentData = await clientHints(['bitness'])
76+
expect(userAgentData).toEqual(lowEntropyTestData)
77+
})
78+
79+
it('gets specified high entropy hints', async () => {
80+
const userAgentData = await clientHints(['bitness'])
81+
expect(userAgentData).toEqual({
82+
...lowEntropyTestData,
83+
bitness: '64',
84+
})
85+
})
86+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { HighEntropyHint, NavigatorUAData, UADataValues } from './interfaces'
2+
3+
export async function clientHints(
4+
hints?: HighEntropyHint[]
5+
): Promise<UADataValues | undefined> {
6+
const userAgentData = (navigator as any).userAgentData as
7+
| NavigatorUAData
8+
| undefined
9+
10+
if (!userAgentData) return undefined
11+
12+
if (!hints) return userAgentData.toJSON()
13+
return userAgentData
14+
.getHighEntropyValues(hints)
15+
.catch(() => userAgentData.toJSON())
16+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// https://wicg.github.io/ua-client-hints/#dictdef-navigatoruabrandversion
2+
export interface NavigatorUABrandVersion {
3+
readonly brand: string
4+
readonly version: string
5+
}
6+
7+
// https://wicg.github.io/ua-client-hints/#dictdef-uadatavalues
8+
export interface UADataValues {
9+
readonly brands?: NavigatorUABrandVersion[]
10+
readonly mobile?: boolean
11+
readonly platform?: string
12+
readonly architecture?: string
13+
readonly bitness?: string
14+
readonly model?: string
15+
readonly platformVersion?: string
16+
/** @deprecated in favour of fullVersionList */
17+
readonly uaFullVersion?: string
18+
readonly fullVersionList?: NavigatorUABrandVersion[]
19+
readonly wow64?: boolean
20+
}
21+
22+
// https://wicg.github.io/ua-client-hints/#dictdef-ualowentropyjson
23+
export interface UALowEntropyJSON {
24+
readonly brands: NavigatorUABrandVersion[]
25+
readonly mobile: boolean
26+
readonly platform: string
27+
}
28+
29+
// https://wicg.github.io/ua-client-hints/#navigatoruadata
30+
export interface NavigatorUAData extends UALowEntropyJSON {
31+
getHighEntropyValues(hints: HighEntropyHint[]): Promise<UADataValues>
32+
toJSON(): UALowEntropyJSON
33+
}
34+
35+
export type HighEntropyHint =
36+
| 'architecture'
37+
| 'bitness'
38+
| 'model'
39+
| 'platformVersion'
40+
| 'uaFullVersion'
41+
| 'fullVersionList'
42+
| 'wow64'

packages/browser/src/plugins/schema-filter/__tests__/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe('schema filter', () => {
104104

105105
options = { apiKey: 'foo' }
106106
ajs = new Analytics({ writeKey: options.apiKey })
107-
segment = segmentio(ajs, options, {})
107+
segment = await segmentio(ajs, options, {})
108108
filterXt = schemaFilter({}, settings)
109109

110110
jest.spyOn(segment, 'track')

0 commit comments

Comments
 (0)