diff --git a/.vscode/settings.json b/.vscode/settings.json index 67bbd4ff1..7869db3b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "jest.rootPath": "/workspaces/javascript-sdk/packages/optimizely-sdk", "jest.jestCommandLine": "./node_modules/.bin/jest", - "jest.autoRevealOutput": "on-exec-error" + "jest.autoRevealOutput": "on-exec-error", + "editor.tabSize": 2 } \ No newline at end of file diff --git a/lib/core/odp/odp_event_manager.ts b/lib/core/odp/odp_event_manager.ts index e8a6744e2..934c2d2fb 100644 --- a/lib/core/odp/odp_event_manager.ts +++ b/lib/core/odp/odp_event_manager.ts @@ -23,6 +23,7 @@ import { OdpEvent } from './odp_event'; import { OdpConfig } from './odp_config'; import { IOdpEventApiManager } from './odp_event_api_manager'; import { invalidOdpDataFound } from './odp_utils'; +import { IUserAgentParser } from './user_agent_parser'; const MAX_RETRIES = 3; @@ -123,6 +124,19 @@ export abstract class OdpEventManager implements IOdpEventManager { */ private readonly clientVersion: string; + /** + * Version of the client being used + * @private + */ + private readonly userAgentParser?: IUserAgentParser; + + + /** + * Information about the user agent + * @private + */ + private readonly userAgentData?: Map; + constructor({ odpConfig, apiManager, @@ -132,6 +146,7 @@ export abstract class OdpEventManager implements IOdpEventManager { queueSize, batchSize, flushInterval, + userAgentParser, }: { odpConfig: OdpConfig; apiManager: IOdpEventApiManager; @@ -141,6 +156,7 @@ export abstract class OdpEventManager implements IOdpEventManager { queueSize?: number; batchSize?: number; flushInterval?: number; + userAgentParser?: IUserAgentParser; }) { this.odpConfig = odpConfig; this.apiManager = apiManager; @@ -149,6 +165,22 @@ export abstract class OdpEventManager implements IOdpEventManager { this.clientVersion = clientVersion; this.initParams(batchSize, queueSize, flushInterval); this.state = STATE.STOPPED; + this.userAgentParser = userAgentParser; + + if (userAgentParser) { + const { os, device } = userAgentParser.parseUserAgentInfo(); + + const userAgentInfo: Record = { + 'os': os.name, + 'os_version': os.version, + 'device_type': device.type, + 'model': device.model, + }; + + this.userAgentData = new Map( + Object.entries(userAgentInfo).filter(([key, value]) => value != null && value != undefined) + ); + } this.apiManager.updateSettings(odpConfig); } @@ -408,7 +440,8 @@ export abstract class OdpEventManager implements IOdpEventManager { * @private */ private augmentCommonData(sourceData: Map): Map { - const data = new Map(); + const data = new Map(this.userAgentData); + data.set('idempotence_id', uuid()); data.set('data_source_type', 'sdk'); data.set('data_source', this.clientEngine); diff --git a/lib/core/odp/user_agent_info.ts b/lib/core/odp/user_agent_info.ts new file mode 100644 index 000000000..e83b3b032 --- /dev/null +++ b/lib/core/odp/user_agent_info.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type UserAgentInfo = { + os: { + name?: string, + version?: string, + }, + device: { + type?: string, + model?: string, + } +}; diff --git a/lib/core/odp/user_agent_parser.ts b/lib/core/odp/user_agent_parser.ts new file mode 100644 index 000000000..227065fb7 --- /dev/null +++ b/lib/core/odp/user_agent_parser.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UserAgentInfo } from "./user_agent_info"; + +export interface IUserAgentParser { + parseUserAgentInfo(): UserAgentInfo, +} diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js index 0fec0d5bc..691723cc1 100644 --- a/lib/index.browser.tests.js +++ b/lib/index.browser.tests.js @@ -67,6 +67,10 @@ if (!global.window) { } } +const pause = (timeoutMilliseconds) => { + return new Promise(resolve => setTimeout(resolve, timeoutMilliseconds)); +}; + describe('javascript-sdk (Browser)', function() { var clock; beforeEach(function() { @@ -838,6 +842,53 @@ describe('javascript-sdk (Browser)', function() { sinon.assert.called(fakeEventManager.sendEvent); }); + it('should augment odp events with user agent data if userAgentParser is provided', async () => { + const userAgentParser = { + parseUserAgentInfo() { + return { + os: { 'name': 'windows', 'version': '11' }, + device: { 'type': 'laptop', 'model': 'thinkpad' }, + } + } + } + + const fakeRequestHandler = { + makeRequest: sinon.spy(function (requestUrl, headers, method, data) { + return { + abort: () => {}, + responsePromise: Promise.resolve({ statusCode: 200 }), + } + }) + }; + + const client = optimizelyFactory.createInstance({ + datafile: testData.getOdpIntegratedConfigWithSegments(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + eventBatchSize: null, + logger, + odpOptions: { + userAgentParser, + eventRequestHandler: fakeRequestHandler, + }, + }); + const readyData = await client.onReady(); + + assert.equal(readyData.success, true); + assert.isUndefined(readyData.reason); + + client.sendOdpEvent('test', '', new Map([['eamil', 'test@test.test']]), new Map([['key', 'value']])); + clock.tick(10000); + + const eventRequestUrl = new URL(fakeRequestHandler.makeRequest.lastCall.args[0]); + const searchParams = eventRequestUrl.searchParams; + + assert.equal(searchParams.get('os'), 'windows'); + assert.equal(searchParams.get('os_version'), '11'); + assert.equal(searchParams.get('device_type'), 'laptop'); + assert.equal(searchParams.get('model'), 'thinkpad'); + }); + it('should convert fs-user-id, FS-USER-ID, and FS_USER_ID to fs_user_id identifier when calling sendOdpEvent', async () => { const fakeEventManager = { updateSettings: sinon.spy(), diff --git a/lib/index.browser.ts b/lib/index.browser.ts index 39f7ccde5..56df474a8 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -28,6 +28,8 @@ import { OptimizelyDecideOption, Client, Config, OptimizelyOptions } from './sha import { createHttpPollingDatafileManager } from './plugins/datafile_manager/browser_http_polling_datafile_manager'; import { BrowserOdpManager } from './plugins/odp_manager/index.browser'; import Optimizely from './optimizely'; +import { IUserAgentParser } from './core/odp/user_agent_parser'; +import { getUserAgentParser } from './plugins/odp/user_agent_parser/index.browser'; const logger = getLogger(); logHelper.setLogHandler(loggerPlugin.createLogger()); @@ -164,6 +166,7 @@ const __internalResetRetryState = function(): void { const setLogHandler = logHelper.setLogHandler; const setLogLevel = logHelper.setLogLevel; + export { loggerPlugin as logging, defaultErrorHandler as errorHandler, @@ -174,6 +177,8 @@ export { createInstance, __internalResetRetryState, OptimizelyDecideOption, + IUserAgentParser, + getUserAgentParser, }; export default { @@ -186,6 +191,7 @@ export default { createInstance, __internalResetRetryState, OptimizelyDecideOption, + getUserAgentParser, }; export * from './export_types'; diff --git a/lib/plugins/odp/user_agent_parser/index.browser.ts b/lib/plugins/odp/user_agent_parser/index.browser.ts new file mode 100644 index 000000000..ad5d5f230 --- /dev/null +++ b/lib/plugins/odp/user_agent_parser/index.browser.ts @@ -0,0 +1,33 @@ +/** + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UAParser } from 'ua-parser-js'; +import { UserAgentInfo } from "../../../core/odp/user_agent_info"; +import { IUserAgentParser } from '../../../core/odp/user_agent_parser'; + +const userAgentParser: IUserAgentParser = { + parseUserAgentInfo(): UserAgentInfo { + const parser = new UAParser(); + const agentInfo = parser.getResult(); + const { os, device } = agentInfo; + return { os, device }; + } +} + +export function getUserAgentParser(): IUserAgentParser { + return userAgentParser; +} + diff --git a/lib/plugins/odp_manager/index.browser.ts b/lib/plugins/odp_manager/index.browser.ts index d7c29c493..989774e5a 100644 --- a/lib/plugins/odp_manager/index.browser.ts +++ b/lib/plugins/odp_manager/index.browser.ts @@ -118,6 +118,7 @@ export class BrowserOdpManager extends OdpManager { flushInterval: odpOptions?.eventFlushInterval, batchSize: odpOptions?.eventBatchSize, queueSize: odpOptions?.eventQueueSize, + userAgentParser: odpOptions?.userAgentParser, }); } diff --git a/lib/shared_types.ts b/lib/shared_types.ts index ff6df7bd7..4ef0de403 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -35,6 +35,7 @@ import { IOdpSegmentManager } from './core/odp/odp_segment_manager'; import { IOdpEventApiManager } from './core/odp/odp_event_api_manager'; import { IOdpEventManager } from './core/odp/odp_event_manager'; import { IOdpManager } from './core/odp/odp_manager'; +import { IUserAgentParser } from './core/odp/user_agent_parser'; export interface BucketerParams { experimentId: string; @@ -105,6 +106,7 @@ export interface OdpOptions { eventApiTimeout?: number; eventRequestHandler?: RequestHandler; eventManager?: IOdpEventManager; + userAgentParser?: IUserAgentParser; } export interface ListenerPayload { diff --git a/package-lock.json b/package-lock.json index 253922ace..3b4c236f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1656,6 +1656,12 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, + "@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "@types/uuid": { "version": "3.4.10", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.10.tgz", @@ -6260,6 +6266,12 @@ "requires": { "rimraf": "^3.0.0" } + }, + "ua-parser-js": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", + "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==", + "dev": true } } }, @@ -8500,10 +8512,9 @@ "dev": true }, "ua-parser-js": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", - "integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==", - "dev": true + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==" }, "universalify": { "version": "0.1.2", diff --git a/package.json b/package.json index 7aedd6626..828de12c0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "decompress-response": "^4.2.1", "json-schema": "^0.4.0", "murmurhash": "^2.0.1", + "ua-parser-js": "^1.0.35", "uuid": "^8.3.2" }, "devDependencies": { @@ -57,6 +58,7 @@ "@types/mocha": "^5.2.7", "@types/nise": "^1.4.0", "@types/node": "^18.7.18", + "@types/ua-parser-js": "^0.7.36", "@types/uuid": "^3.4.4", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", diff --git a/tests/odpEventManager.spec.ts b/tests/odpEventManager.spec.ts index 466b0368b..56c98da46 100644 --- a/tests/odpEventManager.spec.ts +++ b/tests/odpEventManager.spec.ts @@ -23,6 +23,8 @@ import { anything, capture, instance, mock, resetCalls, spy, verify, when } from import { IOdpEventApiManager } from '../lib/core/odp/odp_event_api_manager'; import { LogHandler, LogLevel } from '../lib/modules/logging'; import { OdpEvent } from '../lib/core/odp/odp_event'; +import { IUserAgentParser } from '../lib/core/odp/user_agent_parser'; +import { UserAgentInfo } from '../lib/core/odp/user_agent_info'; const API_KEY = 'test-api-key'; const API_HOST = 'https://odp.example.com'; @@ -372,6 +374,41 @@ describe('OdpEventManager', () => { expect(events[1].data.size).toEqual(PROCESSED_EVENTS[1].data.size); }); + it('should augment events with data from user agent parser', async () => { + const userAgentParser : IUserAgentParser = { + parseUserAgentInfo: function (): UserAgentInfo { + return { + os: { 'name': 'windows', 'version': '11' }, + device: { 'type': 'laptop', 'model': 'thinkpad' }, + } + } + } + + const eventManager = new OdpEventManager({ + odpConfig, + apiManager, + logger, + clientEngine, + clientVersion, + batchSize: 10, + flushInterval: 100, + userAgentParser, + }); + + eventManager.start(); + EVENTS.forEach(event => eventManager.sendEvent(event)); + await pause(1000); + + verify(mockApiManager.sendEvents(anything())).called(); + const [events] = capture(mockApiManager.sendEvents).last(); + const event = events[0]; + + expect(event.data.get('os')).toEqual('windows'); + expect(event.data.get('os_version')).toEqual('11'); + expect(event.data.get('device_type')).toEqual('laptop'); + expect(event.data.get('model')).toEqual('thinkpad'); + }); + it('should retry failed events', async () => { // all events should fail ie shouldRetry = true when(mockApiManager.sendEvents(anything())).thenResolve(true); diff --git a/tests/odpManager.browser.spec.ts b/tests/odpManager.browser.spec.ts index 7f6a265d8..385616593 100644 --- a/tests/odpManager.browser.spec.ts +++ b/tests/odpManager.browser.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { anything, capture, instance, mock, resetCalls, verify } from 'ts-mockito'; +import { anything, capture, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { LOG_MESSAGES, ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION } from './../lib/utils/enums/index'; import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; @@ -24,7 +24,7 @@ import { RequestHandler } from '../lib/utils/http_request_handler/http'; import { BrowserLRUCache } from './../lib/utils/lru_cache/browser_lru_cache'; import { BrowserOdpManager } from './../lib/plugins/odp_manager/index.browser'; -import { OdpOptions } from './../lib/shared_types'; +import { IOdpEventManager, OdpOptions } from './../lib/shared_types'; import { OdpConfig } from '../lib/core/odp/odp_config'; import { BrowserOdpEventApiManager } from '../lib/plugins/odp/event_api_manager/index.browser'; import { BrowserOdpEventManager } from '../lib/plugins/odp/event_manager/index.browser'; @@ -32,6 +32,9 @@ import { OdpSegmentManager } from './../lib/core/odp/odp_segment_manager'; import { OdpSegmentApiManager } from '../lib/core/odp/odp_segment_api_manager'; import { VuidManager } from '../lib/plugins/vuid_manager'; import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser_request_handler'; +import { IUserAgentParser } from '../lib/core/odp/user_agent_parser'; +import { UserAgentInfo } from '../lib/core/odp/user_agent_info'; +import { OdpEvent } from '../lib/core/odp/odp_event'; const keyA = 'key-a'; const hostA = 'host-a';