Skip to content

Commit

Permalink
Introduce Client Hints API (#864)
Browse files Browse the repository at this point in the history
  • Loading branch information
danieljackins authored Jun 6, 2023
1 parent fb6811c commit 6cba535
Show file tree
Hide file tree
Showing 14 changed files with 338 additions and 66 deletions.
6 changes: 6 additions & 0 deletions .changeset/sixty-drinks-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@segment/analytics-next': minor
'@segment/analytics-core': minor
---

Add Client Hints API support
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"size-limit": [
{
"path": "dist/umd/index.js",
"limit": "28.1 KB"
"limit": "28.5 KB"
}
],
"dependencies": {
Expand Down
149 changes: 95 additions & 54 deletions packages/browser/src/browser/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { getCDN, setGlobalCDNUrl } from '../../lib/parse-cdn'
import { clearAjsBrowserStorage } from '../../test-helpers/browser-storage'
import { parseFetchCall } from '../../test-helpers/fetch-parse'
import { ActionDestination } from '../../plugins/remote-loader'
import {
highEntropyTestData,
lowEntropyTestData,
} from '../../lib/client-hints/__tests__/index.test'
import { UADataValues } from '../../lib/client-hints/interfaces'

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

Expand Down Expand Up @@ -207,76 +212,112 @@ describe('Initialization', () => {
})
})

it('calls page if initialpageview is set', async () => {
jest.mock('../../core/analytics')
const mockPage = jest.fn().mockImplementation(() => Promise.resolve())
Analytics.prototype.page = mockPage

await AnalyticsBrowser.load({ writeKey }, { initialPageview: true })
describe('Load options', () => {
it('gets high entropy client hints if set', async () => {
;(window.navigator as any).userAgentData = {
...lowEntropyTestData,
getHighEntropyValues: jest
.fn()
.mockImplementation((hints: string[]): Promise<UADataValues> => {
let result = {}
Object.entries(highEntropyTestData).forEach(([k, v]) => {
if (hints.includes(k)) {
result = {
...result,
[k]: v,
}
}
})
return Promise.resolve({
...lowEntropyTestData,
...result,
})
}),
toJSON: jest.fn(() => lowEntropyTestData),
}

expect(mockPage).toHaveBeenCalled()
})
const [ajs] = await AnalyticsBrowser.load(
{ writeKey },
{ highEntropyValuesClientHints: ['architecture'] }
)

it('does not call page if initialpageview is not set', async () => {
jest.mock('../../core/analytics')
const mockPage = jest.fn()
Analytics.prototype.page = mockPage
await AnalyticsBrowser.load({ writeKey }, { initialPageview: false })
expect(mockPage).not.toHaveBeenCalled()
})
const evt = await ajs.track('foo')
expect(evt.event.context?.userAgentData).toEqual({
...lowEntropyTestData,
architecture: 'x86',
})
})
it('calls page if initialpageview is set', async () => {
jest.mock('../../core/analytics')
const mockPage = jest.fn().mockImplementation(() => Promise.resolve())
Analytics.prototype.page = mockPage

it('does not use a persisted queue when disableClientPersistence is true', async () => {
const [ajs] = await AnalyticsBrowser.load(
{
writeKey,
},
{
disableClientPersistence: true,
}
)
await AnalyticsBrowser.load({ writeKey }, { initialPageview: true })

expect(ajs.queue.queue instanceof PriorityQueue).toBe(true)
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(false)
})
expect(mockPage).toHaveBeenCalled()
})

it('uses a persisted queue by default', async () => {
const [ajs] = await AnalyticsBrowser.load({
writeKey,
it('does not call page if initialpageview is not set', async () => {
jest.mock('../../core/analytics')
const mockPage = jest.fn()
Analytics.prototype.page = mockPage
await AnalyticsBrowser.load({ writeKey }, { initialPageview: false })
expect(mockPage).not.toHaveBeenCalled()
})

expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(true)
})
it('does not use a persisted queue when disableClientPersistence is true', async () => {
const [ajs] = await AnalyticsBrowser.load(
{
writeKey,
},
{
disableClientPersistence: true,
}
)

it('disables identity persistance when disableClientPersistence is true', async () => {
const [ajs] = await AnalyticsBrowser.load(
{
expect(ajs.queue.queue instanceof PriorityQueue).toBe(true)
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(false)
})

it('uses a persisted queue by default', async () => {
const [ajs] = await AnalyticsBrowser.load({
writeKey,
},
{
disableClientPersistence: true,
}
)
})

expect(ajs.user().options.persist).toBe(false)
expect(ajs.group().options.persist).toBe(false)
})
expect(ajs.queue.queue instanceof PersistedPriorityQueue).toBe(true)
})

it('fetch remote source settings by default', async () => {
await AnalyticsBrowser.load({
writeKey,
it('disables identity persistance when disableClientPersistence is true', async () => {
const [ajs] = await AnalyticsBrowser.load(
{
writeKey,
},
{
disableClientPersistence: true,
}
)

expect(ajs.user().options.persist).toBe(false)
expect(ajs.group().options.persist).toBe(false)
})

expect(fetchCalls.length).toBeGreaterThan(0)
expect(fetchCalls[0].url).toMatch(/\/settings$/)
})
it('fetch remote source settings by default', async () => {
await AnalyticsBrowser.load({
writeKey,
})

it('does not fetch source settings if cdnSettings is set', async () => {
await AnalyticsBrowser.load({
writeKey,
cdnSettings: { integrations: {} },
expect(fetchCalls.length).toBeGreaterThan(0)
expect(fetchCalls[0].url).toMatch(/\/settings$/)
})

expect(fetchCalls.length).toBe(0)
it('does not fetch source settings if cdnSettings is set', async () => {
await AnalyticsBrowser.load({
writeKey,
cdnSettings: { integrations: {} },
})

expect(fetchCalls.length).toBe(0)
})
})

describe('options.integrations permutations', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ async function registerPlugins(

if (!shouldIgnoreSegmentio) {
toRegister.push(
segmentio(
await segmentio(
analytics,
mergedSettings['Segment.io'] as SegmentioSettings,
legacySettings.integrations
Expand Down
5 changes: 5 additions & 0 deletions packages/browser/src/core/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { version } from '../../generated/version'
import { PriorityQueue } from '../../lib/priority-queue'
import { getGlobal } from '../../lib/get-global'
import { AnalyticsClassic, AnalyticsCore } from './interfaces'
import { HighEntropyHint } from '../../lib/client-hints/interfaces'

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

/* analytics-classic stubs */
Expand Down
86 changes: 86 additions & 0 deletions packages/browser/src/lib/client-hints/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { clientHints } from '..'
import { UADataValues, UALowEntropyJSON } from '../interfaces'

export const lowEntropyTestData: UALowEntropyJSON = {
brands: [
{
brand: 'Google Chrome',
version: '113',
},
{
brand: 'Chromium',
version: '113',
},
{
brand: 'Not-A.Brand',
version: '24',
},
],
mobile: false,
platform: 'macOS',
}

export const highEntropyTestData: UADataValues = {
architecture: 'x86',
bitness: '64',
}

describe('Client Hints API', () => {
beforeEach(() => {
;(window.navigator as any).userAgentData = {
...lowEntropyTestData,
getHighEntropyValues: jest
.fn()
.mockImplementation((hints: string[]): Promise<UADataValues> => {
let result = {}
Object.entries(highEntropyTestData).forEach(([k, v]) => {
if (hints.includes(k)) {
result = {
...result,
[k]: v,
}
}
})
return Promise.resolve({
...lowEntropyTestData,
...result,
})
}),
toJSON: jest.fn(() => {
return lowEntropyTestData
}),
}
})

it('uses API when available', async () => {
let userAgentData = await clientHints()
expect(userAgentData).toEqual(lowEntropyTestData)
;(window.navigator as any).userAgentData = undefined
userAgentData = await clientHints()
expect(userAgentData).toBe(undefined)
})

it('always gets low entropy hints', async () => {
const userAgentData = await clientHints()
expect(userAgentData).toEqual(lowEntropyTestData)
})

it('gets low entropy hints when client rejects high entropy promise', async () => {
;(window.navigator as any).userAgentData = {
...lowEntropyTestData,
getHighEntropyValues: jest.fn(() => Promise.reject()),
toJSON: jest.fn(() => lowEntropyTestData),
}

const userAgentData = await clientHints(['bitness'])
expect(userAgentData).toEqual(lowEntropyTestData)
})

it('gets specified high entropy hints', async () => {
const userAgentData = await clientHints(['bitness'])
expect(userAgentData).toEqual({
...lowEntropyTestData,
bitness: '64',
})
})
})
16 changes: 16 additions & 0 deletions packages/browser/src/lib/client-hints/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { HighEntropyHint, NavigatorUAData, UADataValues } from './interfaces'

export async function clientHints(
hints?: HighEntropyHint[]
): Promise<UADataValues | undefined> {
const userAgentData = (navigator as any).userAgentData as
| NavigatorUAData
| undefined

if (!userAgentData) return undefined

if (!hints) return userAgentData.toJSON()
return userAgentData
.getHighEntropyValues(hints)
.catch(() => userAgentData.toJSON())
}
42 changes: 42 additions & 0 deletions packages/browser/src/lib/client-hints/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// https://wicg.github.io/ua-client-hints/#dictdef-navigatoruabrandversion
export interface NavigatorUABrandVersion {
readonly brand: string
readonly version: string
}

// https://wicg.github.io/ua-client-hints/#dictdef-uadatavalues
export interface UADataValues {
readonly brands?: NavigatorUABrandVersion[]
readonly mobile?: boolean
readonly platform?: string
readonly architecture?: string
readonly bitness?: string
readonly model?: string
readonly platformVersion?: string
/** @deprecated in favour of fullVersionList */
readonly uaFullVersion?: string
readonly fullVersionList?: NavigatorUABrandVersion[]
readonly wow64?: boolean
}

// https://wicg.github.io/ua-client-hints/#dictdef-ualowentropyjson
export interface UALowEntropyJSON {
readonly brands: NavigatorUABrandVersion[]
readonly mobile: boolean
readonly platform: string
}

// https://wicg.github.io/ua-client-hints/#navigatoruadata
export interface NavigatorUAData extends UALowEntropyJSON {
getHighEntropyValues(hints: HighEntropyHint[]): Promise<UADataValues>
toJSON(): UALowEntropyJSON
}

export type HighEntropyHint =
| 'architecture'
| 'bitness'
| 'model'
| 'platformVersion'
| 'uaFullVersion'
| 'fullVersionList'
| 'wow64'
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('schema filter', () => {

options = { apiKey: 'foo' }
ajs = new Analytics({ writeKey: options.apiKey })
segment = segmentio(ajs, options, {})
segment = await segmentio(ajs, options, {})
filterXt = schemaFilter({}, settings)

jest.spyOn(segment, 'track')
Expand Down
Loading

0 comments on commit 6cba535

Please sign in to comment.