Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Client Hints API #864

Merged
merged 22 commits into from
Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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())
}
danieljackins marked this conversation as resolved.
Show resolved Hide resolved
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