diff --git a/.changeset/sixty-drinks-raise.md b/.changeset/sixty-drinks-raise.md new file mode 100644 index 000000000..3c495e46f --- /dev/null +++ b/.changeset/sixty-drinks-raise.md @@ -0,0 +1,6 @@ +--- +'@segment/analytics-next': minor +'@segment/analytics-core': minor +--- + +Add Client Hints API support diff --git a/packages/browser/package.json b/packages/browser/package.json index 82916c08d..e177a3cd6 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -43,7 +43,7 @@ "size-limit": [ { "path": "dist/umd/index.js", - "limit": "28.1 KB" + "limit": "28.5 KB" } ], "dependencies": { diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index 49a36cb10..daff261ab 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -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[] = [] @@ -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 => { + 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', () => { diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index aa896f054..b85fba41e 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -227,7 +227,7 @@ async function registerPlugins( if (!shouldIgnoreSegmentio) { toRegister.push( - segmentio( + await segmentio( analytics, mergedSettings['Segment.io'] as SegmentioSettings, legacySettings.integrations diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 52fbadfab..507264e58 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -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' @@ -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 */ diff --git a/packages/browser/src/lib/client-hints/__tests__/index.test.ts b/packages/browser/src/lib/client-hints/__tests__/index.test.ts new file mode 100644 index 000000000..728dbe0c5 --- /dev/null +++ b/packages/browser/src/lib/client-hints/__tests__/index.test.ts @@ -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 => { + 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', + }) + }) +}) diff --git a/packages/browser/src/lib/client-hints/index.ts b/packages/browser/src/lib/client-hints/index.ts new file mode 100644 index 000000000..a96270c59 --- /dev/null +++ b/packages/browser/src/lib/client-hints/index.ts @@ -0,0 +1,16 @@ +import { HighEntropyHint, NavigatorUAData, UADataValues } from './interfaces' + +export async function clientHints( + hints?: HighEntropyHint[] +): Promise { + 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()) +} diff --git a/packages/browser/src/lib/client-hints/interfaces.ts b/packages/browser/src/lib/client-hints/interfaces.ts new file mode 100644 index 000000000..0383e635c --- /dev/null +++ b/packages/browser/src/lib/client-hints/interfaces.ts @@ -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 + toJSON(): UALowEntropyJSON +} + +export type HighEntropyHint = + | 'architecture' + | 'bitness' + | 'model' + | 'platformVersion' + | 'uaFullVersion' + | 'fullVersionList' + | 'wow64' diff --git a/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts b/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts index b6e31a9af..abc84559d 100644 --- a/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts +++ b/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts @@ -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') diff --git a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts index 590d593dc..d95ababae 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts @@ -5,6 +5,11 @@ import { Analytics } from '../../../core/analytics' import { Plugin } from '../../../core/plugin' import { pageEnrichment } from '../../page-enrichment' import cookie from 'js-cookie' +import { + highEntropyTestData, + lowEntropyTestData, +} from '../../../lib/client-hints/__tests__/index.test' +import { UADataValues } from '../../../lib/client-hints/interfaces' jest.mock('unfetch', () => { return jest.fn() @@ -19,10 +24,33 @@ describe('Segment.io', () => { beforeEach(async () => { jest.resetAllMocks() jest.restoreAllMocks() + ;(window.navigator as any).userAgentData = { + ...lowEntropyTestData, + getHighEntropyValues: jest + .fn() + .mockImplementation((hints: string[]): Promise => { + 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 + }), + } options = { apiKey: 'foo' } analytics = new Analytics({ writeKey: options.apiKey }) - segment = segmentio(analytics, options, {}) + segment = await segmentio(analytics, options, {}) await analytics.register(segment, pageEnrichment) @@ -54,7 +82,7 @@ describe('Segment.io', () => { protocol: 'http', } const analytics = new Analytics({ writeKey: options.apiKey }) - const segment = segmentio(analytics, options, {}) + const segment = await segmentio(analytics, options, {}) await analytics.register(segment, pageEnrichment) // @ts-ignore test a valid ajsc page call @@ -70,7 +98,7 @@ describe('Segment.io', () => { const analytics = new Analytics({ writeKey: 'foo' }) await analytics.register( - segmentio(analytics, { + await segmentio(analytics, { apiKey: '', deliveryStrategy: { config: { @@ -88,10 +116,10 @@ describe('Segment.io', () => { it('should default to no keepalive', async () => { const analytics = new Analytics({ writeKey: 'foo' }) - const segment = segmentio(analytics, { + const segment = await segmentio(analytics, { apiKey: '', }) - await analytics.register(segment) + await analytics.register(await segment) await analytics.track('foo') const [_, params] = spyMock.mock.lastCall @@ -171,6 +199,14 @@ describe('Segment.io', () => { assert(body.traits == null) assert(body.timestamp) }) + + it('should add userAgentData when available', async () => { + await analytics.track('event') + const [_, params] = spyMock.mock.calls[0] + const body = JSON.parse(params.body) + + expect(body.context?.userAgentData).toEqual(lowEntropyTestData) + }) }) describe('#group', () => { diff --git a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts index 39b2c6486..e73ce2ec9 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts @@ -57,7 +57,7 @@ describe('Segment.io retries', () => { }) } - segment = segmentio(analytics, options, {}) + segment = await segmentio(analytics, options, {}) await analytics.register(segment, pageEnrichment) }) diff --git a/packages/browser/src/plugins/segmentio/index.ts b/packages/browser/src/plugins/segmentio/index.ts index bf24da376..70a76ea65 100644 --- a/packages/browser/src/plugins/segmentio/index.ts +++ b/packages/browser/src/plugins/segmentio/index.ts @@ -12,6 +12,8 @@ import standard, { StandardDispatcherConfig } from './fetch-dispatcher' import { normalize } from './normalize' import { scheduleFlush } from './schedule-flush' import { SEGMENT_API_HOST } from '../../core/constants' +import { clientHints } from '../../lib/client-hints' +import { UADataValues } from '../../lib/client-hints/interfaces' type DeliveryStrategy = | { @@ -50,11 +52,11 @@ function onAlias(analytics: Analytics, json: JSON): JSON { return json } -export function segmentio( +export async function segmentio( analytics: Analytics, settings?: SegmentioSettings, integrations?: LegacySettings['integrations'] -): Plugin { +): Promise { // Attach `pagehide` before buffer is created so that inflight events are added // to the buffer before the buffer persists events in its own `pagehide` handler. window.addEventListener('pagehide', () => { @@ -84,6 +86,15 @@ export function segmentio( ? batch(apiHost, deliveryStrategy.config) : standard(deliveryStrategy?.config as StandardDispatcherConfig) + let userAgentData: UADataValues | undefined + try { + userAgentData = await clientHints( + analytics.options.highEntropyValuesClientHints + ) + } catch { + userAgentData = undefined + } + async function send(ctx: Context): Promise { if (isOffline()) { buffer.push(ctx) @@ -95,6 +106,11 @@ export function segmentio( inflightEvents.add(ctx) const path = ctx.event.type.charAt(0) + + if (userAgentData && ctx.event.context) { + ctx.event.context.userAgentData = userAgentData + } + let json = toFacade(ctx.event).json() if (ctx.event.type === 'track') { diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index af75c2c6a..eb1b6ceed 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -135,7 +135,8 @@ export function normalize( delete json.options json.writeKey = settings?.apiKey - ctx.userAgent = window.navigator.userAgent + + ctx.userAgent = navigator.userAgent // @ts-ignore const locale = navigator.userLanguage || navigator.language diff --git a/packages/core/src/events/interfaces.ts b/packages/core/src/events/interfaces.ts index ba7a3253d..c718c019d 100644 --- a/packages/core/src/events/interfaces.ts +++ b/packages/core/src/events/interfaces.ts @@ -118,6 +118,29 @@ export interface CoreExtraContext { */ userAgent?: string + /** + * User agent data returned by the Client Hints API + */ + userAgentData?: { + brands?: { + brand: string + version: string + }[] + mobile?: boolean + platform?: string + architecture?: string + bitness?: string + model?: string + platformVersion?: string + /** @deprecated in favour of fullVersionList */ + uaFullVersion?: string + fullVersionList?: { + brand: string + version: string + }[] + wow64?: boolean + } + /** * Information about the current library. *