diff --git a/packages/datafile-manager/CHANGELOG.md b/packages/datafile-manager/CHANGELOG.md index 02f0db724..2c2294af7 100644 --- a/packages/datafile-manager/CHANGELOG.md +++ b/packages/datafile-manager/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] Changes that have landed but are not yet released. +### Changed + +- Modified datafile manager to accept, process, and return the datafile's string representation instead of the datafile object. +- Remove JSON parsing of response received from datafile fetch request + - Responsibility of validating the datafile now solely belongs to the project config manager +- Modified React Native async storage cache and persistent value cache implementation to store strings instead of objects as values. + ## [0.7.0] - July 28, 2020 ### Changed diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index 5044b235f..9b52f3aa7 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -69,11 +69,11 @@ class TestDatafileManager extends HttpPollingDatafileManager { } const testCache: PersistentKeyValueCache = { - get(key: string): Promise { - let val = null; + get(key: string): Promise { + let val = ''; switch (key) { case 'opt-datafile-keyThatExists': - val = { name: 'keyThatExists' }; + val = JSON.stringify({ name: 'keyThatExists' }); break; } return Promise.resolve(val); @@ -109,11 +109,11 @@ describe('httpPollingDatafileManager', () => { describe('when constructed with sdkKey and datafile and autoUpdate: true,', () => { beforeEach(() => { - manager = new TestDatafileManager({ datafile: { foo: 'abcd' }, sdkKey: '123', autoUpdate: true }); + manager = new TestDatafileManager({ datafile: JSON.stringify({ foo: 'abcd' }), sdkKey: '123', autoUpdate: true }); }); it('returns the passed datafile from get', () => { - expect(manager.get()).toEqual({ foo: 'abcd' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'abcd' }); }); it('after being started, fetches the datafile, updates itself, and updates itself again after a timeout', async () => { @@ -134,7 +134,7 @@ describe('httpPollingDatafileManager', () => { manager.start(); expect(manager.responsePromises.length).toBe(1); await manager.responsePromises[0]; - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); updateFn.mockReset(); await advanceTimersByTime(300000); @@ -142,20 +142,22 @@ describe('httpPollingDatafileManager', () => { expect(manager.responsePromises.length).toBe(2); await manager.responsePromises[1]; expect(updateFn).toBeCalledTimes(1); - expect(updateFn).toBeCalledWith({ - datafile: { fooz: 'barz' }, - }); - expect(manager.get()).toEqual({ fooz: 'barz' }); + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"fooz": "barz"}' }); + expect(JSON.parse(manager.get())).toEqual({ fooz: 'barz' }); }); }); describe('when constructed with sdkKey and datafile and autoUpdate: false,', () => { beforeEach(() => { - manager = new TestDatafileManager({ datafile: { foo: 'abcd' }, sdkKey: '123', autoUpdate: false }); + manager = new TestDatafileManager({ + datafile: JSON.stringify({ foo: 'abcd' }), + sdkKey: '123', + autoUpdate: false, + }); }); it('returns the passed datafile from get', () => { - expect(manager.get()).toEqual({ foo: 'abcd' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'abcd' }); }); it('after being started, fetches the datafile, updates itself once, but does not schedule a future update', async () => { @@ -167,7 +169,7 @@ describe('httpPollingDatafileManager', () => { manager.start(); expect(manager.responsePromises.length).toBe(1); await manager.responsePromises[0]; - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); expect(getTimerCount()).toBe(0); }); }); @@ -179,7 +181,7 @@ describe('httpPollingDatafileManager', () => { describe('initial state', () => { it('returns null from get before becoming ready', () => { - expect(manager.get()).toBeNull(); + expect(manager.get()).toEqual(''); }); }); @@ -205,28 +207,7 @@ describe('httpPollingDatafileManager', () => { }); manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); - }); - - it('does not update if the response body is not valid json', async () => { - manager.queuedResponses.push( - { - statusCode: 200, - body: '{"foo" "', - headers: {}, - }, - { - statusCode: 200, - body: '{"foo": "bar"}', - headers: {}, - } - ); - manager.start(); - await manager.onReady(); - await advanceTimersByTime(1000); - expect(manager.responsePromises.length).toBe(2); - await manager.responsePromises[1]; - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); }); describe('live updates', () => { @@ -275,22 +256,22 @@ describe('httpPollingDatafileManager', () => { manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); expect(updateFn).toBeCalledTimes(0); await advanceTimersByTime(1000); await manager.responsePromises[1]; expect(updateFn).toBeCalledTimes(1); - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: { foo2: 'bar2' } }); - expect(manager.get()).toEqual({ foo2: 'bar2' }); + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo2": "bar2"}' }); + expect(JSON.parse(manager.get())).toEqual({ foo2: 'bar2' }); updateFn.mockReset(); await advanceTimersByTime(1000); await manager.responsePromises[2]; expect(updateFn).toBeCalledTimes(1); - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: { foo3: 'bar3' } }); - expect(manager.get()).toEqual({ foo3: 'bar3' }); + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo3": "bar3"}' }); + expect(JSON.parse(manager.get())).toEqual({ foo3: 'bar3' }); }); describe('when the update interval time fires before the request is complete', () => { @@ -351,7 +332,7 @@ describe('httpPollingDatafileManager', () => { manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); advanceTimersByTime(1000); @@ -359,7 +340,7 @@ describe('httpPollingDatafileManager', () => { manager.stop(); await manager.responsePromises[1]; // Should not have updated datafile since manager was stopped - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); }); it('calls abort on the current request if there is a current request when stop is called', async () => { @@ -399,7 +380,7 @@ describe('httpPollingDatafileManager', () => { // Trigger the update, should fetch the next response which should succeed, then we get ready advanceTimersByTime(1000); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); }); describe('newness checking', () => { @@ -424,7 +405,7 @@ describe('httpPollingDatafileManager', () => { manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); // First response promise was for the initial 200 response expect(manager.responsePromises.length).toBe(1); // Trigger the queued update @@ -434,7 +415,7 @@ describe('httpPollingDatafileManager', () => { await manager.responsePromises[1]; // Since the response was 304, updateFn should not have been called expect(updateFn).toBeCalledTimes(0); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); }); it('sends if-modified-since using the last observed response last-modified', async () => { @@ -559,7 +540,7 @@ describe('httpPollingDatafileManager', () => { }); manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); }); it('does not schedule a live update after ready', async () => { @@ -659,9 +640,9 @@ describe('httpPollingDatafileManager', () => { manager.on('update', updateFn); manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ name: 'keyThatExists' }); + expect(JSON.parse(manager.get())).toEqual({ name: 'keyThatExists' }); await advanceTimersByTime(50); - expect(manager.get()).toEqual({ name: 'keyThatExists' }); + expect(JSON.parse(manager.get())).toEqual({ name: 'keyThatExists' }); expect(updateFn).toBeCalledTimes(0); }); @@ -676,10 +657,10 @@ describe('httpPollingDatafileManager', () => { manager.on('update', updateFn); manager.start(); await manager.onReady(); - expect(manager.get()).toEqual({ name: 'keyThatExists' }); + expect(JSON.parse(manager.get())).toEqual({ name: 'keyThatExists' }); expect(updateFn).toBeCalledTimes(0); await advanceTimersByTime(50); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); expect(updateFn).toBeCalledTimes(1); }); @@ -693,8 +674,9 @@ describe('httpPollingDatafileManager', () => { manager.start(); await manager.onReady(); await advanceTimersByTime(50); - expect(manager.get()).toEqual({ foo: 'bar' }); - expect(cacheSetSpy).toBeCalledWith('opt-datafile-keyThatExists', { foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); + expect(cacheSetSpy.mock.calls[0][0]).toEqual('opt-datafile-keyThatExists'); + expect(JSON.parse(cacheSetSpy.mock.calls[0][1])).toEqual({ foo: 'bar' }); }); }); @@ -721,7 +703,7 @@ describe('httpPollingDatafileManager', () => { manager.start(); await advanceTimersByTime(50); await manager.onReady(); - expect(manager.get()).toEqual({ foo: 'bar' }); + expect(JSON.parse(manager.get())).toEqual({ foo: 'bar' }); expect(updateFn).toBeCalledTimes(0); }); }); diff --git a/packages/datafile-manager/__test__/reactNativeAsyncStorageCache.spec.ts b/packages/datafile-manager/__test__/reactNativeAsyncStorageCache.spec.ts index 9084434de..0401a2065 100644 --- a/packages/datafile-manager/__test__/reactNativeAsyncStorageCache.spec.ts +++ b/packages/datafile-manager/__test__/reactNativeAsyncStorageCache.spec.ts @@ -24,46 +24,28 @@ describe('reactNativeAsyncStorageCache', () => { }); describe('get', function() { - it('should return correct object when item is found in cache', function() { - return cacheInstance.get('keyThatExists').then(v => expect(v).toEqual({ name: 'Awesome Object' })); + it('should return correct string when item is found in cache', function() { + return cacheInstance.get('keyThatExists').then(v => expect(JSON.parse(v)).toEqual({ name: 'Awesome Object' })); }); - it('should return null if item is not found in cache', function() { - return cacheInstance.get('keyThatDoesNotExist').then(v => expect(v).toBeNull()); - }); - - it('should reject promise error if string has an incorrect JSON format', function() { - return cacheInstance - .get('keyWithInvalidJsonObject') - .catch(() => 'exception caught') - .then(v => { - expect(v).toEqual('exception caught'); - }); + it('should return empty string if item is not found in cache', function() { + return cacheInstance.get('keyThatDoesNotExist').then(v => expect(v).toEqual('')); }); }); describe('set', function() { it('should resolve promise if item was successfully set in the cache', function() { const testObj = { name: 'Awesome Object' }; - return cacheInstance.set('testKey', testObj); - }); - - it('should reject promise if item was not set in the cache because of json stringifying error', function() { - const testObj: any = { name: 'Awesome Object' }; - testObj.myOwnReference = testObj; - return cacheInstance - .set('testKey', testObj) - .catch(() => 'exception caught') - .then(v => expect(v).toEqual('exception caught')); + return cacheInstance.set('testKey', JSON.stringify(testObj)); }); }); describe('contains', function() { - it('should return true if object with key exists', function() { + it('should return true if value with key exists', function() { return cacheInstance.contains('keyThatExists').then(v => expect(v).toBeTruthy()); }); - it('should return false if object with key does not exist', function() { + it('should return false if value with key does not exist', function() { return cacheInstance.contains('keyThatDoesNotExist').then(v => expect(v).toBeFalsy()); }); }); diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts index 9e415bfc6..f93c8551f 100644 --- a/packages/datafile-manager/src/datafileManager.ts +++ b/packages/datafile-manager/src/datafileManager.ts @@ -16,7 +16,7 @@ import PersistentKeyValueCache from './persistentKeyValueCache'; export interface DatafileUpdate { - datafile: object; + datafile: string; } export interface DatafileUpdateListener { @@ -31,14 +31,14 @@ interface Managed { } export interface DatafileManager extends Managed { - get: () => object | null; + get: () => string; on: (eventName: string, listener: DatafileUpdateListener) => () => void; onReady: () => Promise; } export interface DatafileManagerConfig { autoUpdate?: boolean; - datafile?: object; + datafile?: string; sdkKey: string; updateInterval?: number; urlTemplate?: string; diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index 4c41adfc8..f8232c0bd 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -36,8 +36,8 @@ function isSuccessStatusCode(statusCode: number): boolean { } const noOpKeyValueCache: PersistentKeyValueCache = { - get(): Promise { - return Promise.resolve(null); + get(): Promise { + return Promise.resolve(''); }, set(): Promise { @@ -63,7 +63,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana // Return any default configuration options that should be applied protected abstract getConfigDefaults(): Partial; - private currentDatafile: object | null; + private currentDatafile: string; private readonly readyPromise: Promise; @@ -131,7 +131,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana this.resolveReadyPromise(); } } else { - this.currentDatafile = null; + this.currentDatafile = ''; } this.isStarted = false; @@ -152,7 +152,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana this.syncOnCurrentRequestComplete = false; } - get(): object | null { + get(): string { return this.currentDatafile; } @@ -222,7 +222,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana this.trySavingLastModified(response.headers); const datafile = this.getNextDatafileFromResponse(response); - if (datafile !== null) { + if (datafile !== '') { logger.info('Updating datafile from response'); this.currentDatafile = datafile; this.cache.set(this.cacheKey, datafile); @@ -305,35 +305,18 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana }, nextUpdateDelay); } - private getNextDatafileFromResponse(response: Response): object | null { + private getNextDatafileFromResponse(response: Response): string { logger.debug('Response status code: %s', response.statusCode); if (typeof response.statusCode === 'undefined') { - return null; + return ''; } if (response.statusCode === 304) { - return null; + return ''; } if (isSuccessStatusCode(response.statusCode)) { - return this.tryParsingBodyAsJSON(response.body); + return response.body; } - return null; - } - - private tryParsingBodyAsJSON(body: string): object | null { - let parseResult: any; - try { - parseResult = JSON.parse(body); - } catch (err) { - logger.error('Error parsing response body: %s', err.message, err); - return null; - } - let datafileObj: object | null = null; - if (typeof parseResult === 'object' && parseResult !== null) { - datafileObj = parseResult; - } else { - logger.error('Error parsing response body: was not an object'); - } - return datafileObj; + return ''; } private trySavingLastModified(headers: Headers): void { @@ -346,7 +329,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana setDatafileFromCacheIfAvailable(): void { this.cache.get(this.cacheKey).then(datafile => { - if (this.isStarted && !this.isReadyPromiseSettled && datafile) { + if (this.isStarted && !this.isReadyPromiseSettled && datafile !== '') { logger.debug('Using datafile from cache'); this.currentDatafile = datafile; this.resolveReadyPromise(); diff --git a/packages/datafile-manager/src/persistentKeyValueCache.ts b/packages/datafile-manager/src/persistentKeyValueCache.ts index 9b3ef9fac..08dfcf1fe 100644 --- a/packages/datafile-manager/src/persistentKeyValueCache.ts +++ b/packages/datafile-manager/src/persistentKeyValueCache.ts @@ -15,8 +15,7 @@ */ /** - * An Interface to implement a persistent key value cache which supports strings as keys - * and JSON Object as value. + * An Interface to implement a persistent key value cache which supports strings as keys and values. */ export default interface PersistentKeyValueCache { /** @@ -24,21 +23,21 @@ export default interface PersistentKeyValueCache { * @param key * @returns * Resolves promise with - * 1. Object if value found was stored as a JSON Object. + * 1. string if value found was stored as a string. * 2. null if the key does not exist in the cache. * Rejects the promise in case of an error */ - get(key: string): Promise; + get(key: string): Promise; /** - * Stores Object in the persistent cache against a key + * Stores string in the persistent cache against a key * @param key * @param val * @returns * Resolves promise without a value if successful * Rejects the promise in case of an error */ - set(key: string, val: any): Promise; + set(key: string, val: string): Promise; /** * Checks if a key exists in the cache diff --git a/packages/datafile-manager/src/reactNativeAsyncStorageCache.ts b/packages/datafile-manager/src/reactNativeAsyncStorageCache.ts index 73f0bb364..d0a6b46d0 100644 --- a/packages/datafile-manager/src/reactNativeAsyncStorageCache.ts +++ b/packages/datafile-manager/src/reactNativeAsyncStorageCache.ts @@ -14,35 +14,22 @@ * limitations under the License. */ -import { getLogger } from '@optimizely/js-sdk-logging'; import AsyncStorage from '@react-native-community/async-storage'; import PersistentKeyValueCache from './persistentKeyValueCache'; -const logger = getLogger('DatafileManager'); - export default class ReactNativeAsyncStorageCache implements PersistentKeyValueCache { - get(key: string): Promise { + get(key: string): Promise { return AsyncStorage.getItem(key).then((val: string | null) => { if (!val) { - return null; - } - try { - return JSON.parse(val); - } catch (ex) { - logger.error('Error Parsing Object from cache - %s', ex); - throw ex; + return ''; } + return val; }); } - set(key: string, val: any): Promise { - try { - return AsyncStorage.setItem(key, JSON.stringify(val)); - } catch (ex) { - logger.error('Error stringifying Object to Json - %s', ex); - return Promise.reject(ex); - } + set(key: string, val: string): Promise { + return AsyncStorage.setItem(key, val); } contains(key: string): Promise { diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/index.js b/packages/optimizely-sdk/lib/core/audience_evaluator/index.js index 884a8b3fd..aaca09a79 100644 --- a/packages/optimizely-sdk/lib/core/audience_evaluator/index.js +++ b/packages/optimizely-sdk/lib/core/audience_evaluator/index.js @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; -import { getLogger } from '@optimizely/js-sdk-logging'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); import fns from '../../utils/fns'; import { diff --git a/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js b/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js index abc6fc377..286cd9a3f 100644 --- a/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js +++ b/packages/optimizely-sdk/lib/core/audience_evaluator/index.tests.js @@ -15,7 +15,8 @@ */ import sinon from 'sinon'; import { assert } from 'chai'; -import { getLogger } from '@optimizely/js-sdk-logging'; +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); import AudienceEvaluator from './index'; import conditionTreeEvaluator from '../condition_tree_evaluator'; diff --git a/packages/optimizely-sdk/lib/core/bucketer/index.js b/packages/optimizely-sdk/lib/core/bucketer/index.js index c6beae0d0..fc18a5639 100644 --- a/packages/optimizely-sdk/lib/core/bucketer/index.js +++ b/packages/optimizely-sdk/lib/core/bucketer/index.js @@ -17,7 +17,9 @@ /** * Bucketer API for determining the variation id from the specified parameters */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + import murmurhash from 'murmurhash'; import { diff --git a/packages/optimizely-sdk/lib/core/bucketer/index.tests.js b/packages/optimizely-sdk/lib/core/bucketer/index.tests.js index 8308301ab..9f291af20 100644 --- a/packages/optimizely-sdk/lib/core/bucketer/index.tests.js +++ b/packages/optimizely-sdk/lib/core/bucketer/index.tests.js @@ -16,7 +16,8 @@ import sinon from 'sinon'; import { assert, expect } from 'chai'; import { cloneDeep } from 'lodash'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import bucketer from './'; import { diff --git a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js index 09dff134a..6eff84d4c 100644 --- a/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js +++ b/packages/optimizely-sdk/lib/core/custom_attribute_condition_evaluator/index.js @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * * limitations under the License. * ***************************************************************************/ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import fns from '../../utils/fns'; import { diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.js b/packages/optimizely-sdk/lib/core/decision_service/index.js index 6b0028da8..9399bd9c7 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.js @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * * limitations under the License. * ***************************************************************************/ -import { sprintf } from'@optimizely/js-sdk-utils'; +// import { sprintf } from'@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import bucketer from '../bucketer'; import enums from '../../utils/enums'; diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js b/packages/optimizely-sdk/lib/core/decision_service/index.tests.js index 8551d87ae..a79e6b223 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.tests.js @@ -16,7 +16,8 @@ import sinon from 'sinon'; import { assert } from 'chai'; import cloneDeep from 'lodash/cloneDeep'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import DecisionService from './'; import bucketer from '../bucketer'; diff --git a/packages/optimizely-sdk/lib/core/event_builder/event_helpers.js b/packages/optimizely-sdk/lib/core/event_builder/event_helpers.js index 65aaf2e1f..0290aec75 100644 --- a/packages/optimizely-sdk/lib/core/event_builder/event_helpers.js +++ b/packages/optimizely-sdk/lib/core/event_builder/event_helpers.js @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getLogger } from '@optimizely/js-sdk-logging'; +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); import fns from '../../utils/fns'; import projectConfig from '../project_config'; diff --git a/packages/optimizely-sdk/lib/core/notification_center/index.js b/packages/optimizely-sdk/lib/core/notification_center/index.js index 2b2e65929..52faa5d5e 100644 --- a/packages/optimizely-sdk/lib/core/notification_center/index.js +++ b/packages/optimizely-sdk/lib/core/notification_center/index.js @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf, objectValues } from '@optimizely/js-sdk-utils'; +// import { sprintf, objectValues } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); +var objectValues = require('../../pkg-utils/index'); import { LOG_LEVEL, diff --git a/packages/optimizely-sdk/lib/core/project_config/index.js b/packages/optimizely-sdk/lib/core/project_config/index.js index e11236ac3..302308cfd 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.js @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf, objectValues } from '@optimizely/js-sdk-utils'; +// import { sprintf, objectValues } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); +var objectValues = require('../../pkg-utils/index'); import fns from '../../utils/fns'; import { diff --git a/packages/optimizely-sdk/lib/core/project_config/index.tests.js b/packages/optimizely-sdk/lib/core/project_config/index.tests.js index 1840e72cf..0f6a88478 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -16,8 +16,10 @@ import sinon from 'sinon'; import { assert } from 'chai'; import { forEach, cloneDeep } from 'lodash'; -import { getLogger } from '@optimizely/js-sdk-logging'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../../logging/logger'); +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import fns from '../../utils/fns'; import projectConfig from './'; diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js index b2d177ed8..de41ba5e4 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -13,9 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; -import { getLogger } from '@optimizely/js-sdk-logging'; -import { HttpPollingDatafileManager } from '@optimizely/js-sdk-datafile-manager'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); +// import { HttpPollingDatafileManager } from '../../datafile-manager/src/httpPollingDatafileManager.ts'; +var HttpPollingDatafileManager = require('../../datafile-manager/src/httpPollingDatafileManager.ts'); + import fns from '../../utils/fns'; import { ERROR_MESSAGES } from '../../utils/enums'; diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js index e43b95fc5..2ad9589ed 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -16,9 +16,14 @@ import sinon from 'sinon'; import { assert } from 'chai'; import { cloneDeep } from 'lodash'; -import { sprintf } from '@optimizely/js-sdk-utils'; -import * as logging from '@optimizely/js-sdk-logging'; -import * as datafileManager from '@optimizely/js-sdk-datafile-manager'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); +// import * as logging from '@optimizely/js-sdk-logging'; +var logging = require('../logging/logger'); + +// import * as datafileManager from '../../datafile-manager/src/datafileManager.ts'; +var datafileManager = require('../../datafile-manager/datafileManager.ts'); + import projectConfig from './index'; import { ERROR_MESSAGES, LOG_MESSAGES } from '../../utils/enums'; diff --git a/packages/optimizely-sdk/lib/datafile-manager/backoffController.ts b/packages/optimizely-sdk/lib/datafile-manager/backoffController.ts new file mode 100644 index 000000000..8021f8cbd --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/backoffController.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 { BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT } from './config'; + +function randomMilliseconds(): number { + return Math.round(Math.random() * 1000); +} + +export default class BackoffController { + private errorCount = 0; + + getDelay(): number { + if (this.errorCount === 0) { + return 0; + } + const baseWaitSeconds = + BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT[ + Math.min(BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1, this.errorCount) + ]; + return baseWaitSeconds * 1000 + randomMilliseconds(); + } + + countError(): void { + if (this.errorCount < BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1) { + this.errorCount++; + } + } + + reset(): void { + this.errorCount = 0; + } +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/browserDatafileManager.ts b/packages/optimizely-sdk/lib/datafile-manager/browserDatafileManager.ts new file mode 100644 index 000000000..cc7cb046a --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/browserDatafileManager.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 { makeGetRequest } from './browserRequest'; +import HttpPollingDatafileManager from './httpPollingDatafileManager'; +import { Headers, AbortableRequest } from './http'; +import { DatafileManagerConfig } from './datafileManager'; + +export default class BrowserDatafileManager extends HttpPollingDatafileManager { + protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + return makeGetRequest(reqUrl, headers); + } + + protected getConfigDefaults(): Partial { + return { + autoUpdate: false, + }; + } +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/browserRequest.ts b/packages/optimizely-sdk/lib/datafile-manager/browserRequest.ts new file mode 100644 index 000000000..0d761a5c1 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/browserRequest.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 { AbortableRequest, Response, Headers } from './http'; +import { REQUEST_TIMEOUT_MS } from './config'; +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); + +const logger = getLogger('DatafileManager'); + +const GET_METHOD = 'GET'; +const READY_STATE_DONE = 4; + +function parseHeadersFromXhr(req: XMLHttpRequest): Headers { + const allHeadersString = req.getAllResponseHeaders(); + + if (allHeadersString === null) { + return {}; + } + + const headerLines = allHeadersString.split('\r\n'); + const headers: Headers = {}; + headerLines.forEach(headerLine => { + const separatorIndex = headerLine.indexOf(': '); + if (separatorIndex > -1) { + const headerName = headerLine.slice(0, separatorIndex); + const headerValue = headerLine.slice(separatorIndex + 2); + if (headerValue.length > 0) { + headers[headerName] = headerValue; + } + } + }); + return headers; +} + +function setHeadersInXhr(headers: Headers, req: XMLHttpRequest): void { + Object.keys(headers).forEach(headerName => { + const header = headers[headerName]; + req.setRequestHeader(headerName, header!); + }); +} + +export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + const req = new XMLHttpRequest(); + + const responsePromise: Promise = new Promise((resolve, reject) => { + req.open(GET_METHOD, reqUrl, true); + + setHeadersInXhr(headers, req); + + req.onreadystatechange = (): void => { + if (req.readyState === READY_STATE_DONE) { + const statusCode = req.status; + if (statusCode === 0) { + reject(new Error('Request error')); + return; + } + + const headers = parseHeadersFromXhr(req); + const resp: Response = { + statusCode: req.status, + body: req.responseText, + headers, + }; + resolve(resp); + } + }; + + req.timeout = REQUEST_TIMEOUT_MS; + + req.ontimeout = (): void => { + logger.error('Request timed out'); + }; + + req.send(); + }); + + return { + responsePromise, + abort(): void { + req.abort(); + }, + }; +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/config.ts b/packages/optimizely-sdk/lib/datafile-manager/config.ts new file mode 100644 index 000000000..7c31d8bd0 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/config.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 const DEFAULT_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes + +export const MIN_UPDATE_INTERVAL = 1000; + +export const DEFAULT_URL_TEMPLATE = `https://cdn.optimizely.com/datafiles/%s.json`; + +export const DEFAULT_AUTHENTICATED_URL_TEMPLATE = `https://config.optimizely.com/datafiles/auth/%s.json`; + +export const BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT = [0, 8, 16, 32, 64, 128, 256, 512]; + +export const REQUEST_TIMEOUT_MS = 60 * 1000; // 1 minute diff --git a/packages/optimizely-sdk/lib/datafile-manager/datafileManager.ts b/packages/optimizely-sdk/lib/datafile-manager/datafileManager.ts new file mode 100644 index 000000000..f93c8551f --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/datafileManager.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 PersistentKeyValueCache from './persistentKeyValueCache'; + +export interface DatafileUpdate { + datafile: string; +} + +export interface DatafileUpdateListener { + (datafileUpdate: DatafileUpdate): void; +} + +// TODO: Replace this with the one from js-sdk-models +interface Managed { + start(): void; + + stop(): Promise; +} + +export interface DatafileManager extends Managed { + get: () => string; + on: (eventName: string, listener: DatafileUpdateListener) => () => void; + onReady: () => Promise; +} + +export interface DatafileManagerConfig { + autoUpdate?: boolean; + datafile?: string; + sdkKey: string; + updateInterval?: number; + urlTemplate?: string; + cache?: PersistentKeyValueCache; +} + +export interface NodeDatafileManagerConfig extends DatafileManagerConfig { + datafileAccessToken?: string; +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/eventEmitter.ts b/packages/optimizely-sdk/lib/datafile-manager/eventEmitter.ts new file mode 100644 index 000000000..bf4eee44e --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/eventEmitter.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 Disposer = () => void; + +export type Listener = (arg?: any) => void; + +interface Listeners { + [index: string]: { + // index is event name + [index: string]: Listener; // index is listener id + }; +} + +export default class EventEmitter { + private listeners: Listeners = {}; + + private listenerId = 1; + + on(eventName: string, listener: Listener): Disposer { + if (!this.listeners[eventName]) { + this.listeners[eventName] = {}; + } + const currentListenerId = String(this.listenerId); + this.listenerId++; + this.listeners[eventName][currentListenerId] = listener; + return (): void => { + if (this.listeners[eventName]) { + delete this.listeners[eventName][currentListenerId]; + } + }; + } + + emit(eventName: string, arg?: any): void { + const listeners = this.listeners[eventName]; + if (listeners) { + Object.keys(listeners).forEach(listenerId => { + const listener = listeners[listenerId]; + listener(arg); + }); + } + } + + removeAllListeners(): void { + this.listeners = {}; + } +} + +// TODO: Create a typed event emitter for use in TS only (not JS) diff --git a/packages/optimizely-sdk/lib/datafile-manager/http.ts b/packages/optimizely-sdk/lib/datafile-manager/http.ts new file mode 100644 index 000000000..41503b1aa --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/http.ts @@ -0,0 +1,37 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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. + */ + +/** + * Headers is the interface that bridges between the abstract datafile manager and + * any Node-or-browser-specific http header types. + * It's simplified and can only store one value per header name. + * We can extend or replace this type if requirements change and we need + * to work with multiple values per header name. + */ +export interface Headers { + [header: string]: string | undefined; +} + +export interface Response { + statusCode?: number; + body: string; + headers: Headers; +} + +export interface AbortableRequest { + abort(): void; + responsePromise: Promise; +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/httpPollingDatafileManager.ts b/packages/optimizely-sdk/lib/datafile-manager/httpPollingDatafileManager.ts new file mode 100644 index 000000000..25029c633 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/httpPollingDatafileManager.ts @@ -0,0 +1,341 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../pkg-utils/index'); +import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager'; +import EventEmitter, { Disposer } from './eventEmitter'; +import { AbortableRequest, Response, Headers } from './http'; +import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE } from './config'; +import BackoffController from './backoffController'; +import PersistentKeyValueCache from './persistentKeyValueCache'; + +const logger = getLogger('DatafileManager'); + +const UPDATE_EVT = 'update'; + +function isValidUpdateInterval(updateInterval: number): boolean { + return updateInterval >= MIN_UPDATE_INTERVAL; +} + +function isSuccessStatusCode(statusCode: number): boolean { + return statusCode >= 200 && statusCode < 400; +} + +const noOpKeyValueCache: PersistentKeyValueCache = { + get(): Promise { + return Promise.resolve(''); + }, + + set(): Promise { + return Promise.resolve(); + }, + + contains(): Promise { + return Promise.resolve(false); + }, + + remove(): Promise { + return Promise.resolve(); + }, +}; + +export default abstract class HttpPollingDatafileManager implements DatafileManager { + // Make an HTTP get request to the given URL with the given headers + // Return an AbortableRequest, which has a promise for a Response. + // If we can't get a response, the promise is rejected. + // The request will be aborted if the manager is stopped while the request is in flight. + protected abstract makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest; + + // Return any default configuration options that should be applied + protected abstract getConfigDefaults(): Partial; + + private currentDatafile: string; + + private readonly readyPromise: Promise; + + private isReadyPromiseSettled: boolean; + + private readyPromiseResolver: () => void; + + private readyPromiseRejecter: (err: Error) => void; + + private readonly emitter: EventEmitter; + + private readonly autoUpdate: boolean; + + private readonly updateInterval: number; + + private currentTimeout: any; + + private isStarted: boolean; + + private lastResponseLastModified?: string; + + private datafileUrl: string; + + private currentRequest: AbortableRequest | null; + + private backoffController: BackoffController; + + private cacheKey: string; + + private cache: PersistentKeyValueCache; + + // When true, this means the update interval timeout fired before the current + // sync completed. In that case, we should sync again immediately upon + // completion of the current request, instead of waiting another update + // interval. + private syncOnCurrentRequestComplete: boolean; + + constructor(config: DatafileManagerConfig) { + const configWithDefaultsApplied: DatafileManagerConfig = { + ...this.getConfigDefaults(), + ...config, + }; + const { + datafile, + autoUpdate = false, + sdkKey, + updateInterval = DEFAULT_UPDATE_INTERVAL, + urlTemplate = DEFAULT_URL_TEMPLATE, + cache = noOpKeyValueCache, + } = configWithDefaultsApplied; + + this.cache = cache; + this.cacheKey = 'opt-datafile-' + sdkKey; + this.isReadyPromiseSettled = false; + this.readyPromiseResolver = (): void => {}; + this.readyPromiseRejecter = (): void => {}; + this.readyPromise = new Promise((resolve, reject) => { + this.readyPromiseResolver = resolve; + this.readyPromiseRejecter = reject; + }); + + if (datafile) { + this.currentDatafile = datafile; + if (!sdkKey) { + this.resolveReadyPromise(); + } + } else { + this.currentDatafile = ''; + } + + this.isStarted = false; + + this.datafileUrl = sprintf(urlTemplate, sdkKey); + + this.emitter = new EventEmitter(); + this.autoUpdate = autoUpdate; + if (isValidUpdateInterval(updateInterval)) { + this.updateInterval = updateInterval; + } else { + logger.warn('Invalid updateInterval %s, defaulting to %s', updateInterval, DEFAULT_UPDATE_INTERVAL); + this.updateInterval = DEFAULT_UPDATE_INTERVAL; + } + this.currentTimeout = null; + this.currentRequest = null; + this.backoffController = new BackoffController(); + this.syncOnCurrentRequestComplete = false; + } + + get(): string { + return this.currentDatafile; + } + + start(): void { + if (!this.isStarted) { + logger.debug('Datafile manager started'); + this.isStarted = true; + this.backoffController.reset(); + this.setDatafileFromCacheIfAvailable(); + this.syncDatafile(); + } + } + + stop(): Promise { + logger.debug('Datafile manager stopped'); + this.isStarted = false; + if (this.currentTimeout) { + clearTimeout(this.currentTimeout); + this.currentTimeout = null; + } + + this.emitter.removeAllListeners(); + + if (this.currentRequest) { + this.currentRequest.abort(); + this.currentRequest = null; + } + + return Promise.resolve(); + } + + onReady(): Promise { + return this.readyPromise; + } + + on(eventName: string, listener: (datafileUpdate: DatafileUpdate) => void): Disposer { + return this.emitter.on(eventName, listener); + } + + private onRequestRejected(err: any): void { + if (!this.isStarted) { + return; + } + + this.backoffController.countError(); + + if (err instanceof Error) { + logger.error('Error fetching datafile: %s', err.message, err); + } else if (typeof err === 'string') { + logger.error('Error fetching datafile: %s', err); + } else { + logger.error('Error fetching datafile'); + } + } + + private onRequestResolved(response: Response): void { + if (!this.isStarted) { + return; + } + + if (typeof response.statusCode !== 'undefined' && isSuccessStatusCode(response.statusCode)) { + this.backoffController.reset(); + } else { + this.backoffController.countError(); + } + + this.trySavingLastModified(response.headers); + + const datafile = this.getNextDatafileFromResponse(response); + if (datafile !== '') { + logger.info('Updating datafile from response'); + this.currentDatafile = datafile; + this.cache.set(this.cacheKey, datafile); + if (!this.isReadyPromiseSettled) { + this.resolveReadyPromise(); + } else { + const datafileUpdate: DatafileUpdate = { + datafile, + }; + this.emitter.emit(UPDATE_EVT, datafileUpdate); + } + } + } + + private onRequestComplete(this: HttpPollingDatafileManager): void { + if (!this.isStarted) { + return; + } + + this.currentRequest = null; + + if (!this.isReadyPromiseSettled && !this.autoUpdate) { + // We will never resolve ready, so reject it + this.rejectReadyPromise(new Error('Failed to become ready')); + } + + if (this.autoUpdate && this.syncOnCurrentRequestComplete) { + this.syncDatafile(); + } + this.syncOnCurrentRequestComplete = false; + } + + private syncDatafile(): void { + const headers: Headers = {}; + if (this.lastResponseLastModified) { + headers['if-modified-since'] = this.lastResponseLastModified; + } + + logger.debug('Making datafile request to url %s with headers: %s', this.datafileUrl, () => JSON.stringify(headers)); + this.currentRequest = this.makeGetRequest(this.datafileUrl, headers); + + const onRequestComplete = (): void => { + this.onRequestComplete(); + }; + const onRequestResolved = (response: Response): void => { + this.onRequestResolved(response); + }; + const onRequestRejected = (err: any): void => { + this.onRequestRejected(err); + }; + this.currentRequest.responsePromise + .then(onRequestResolved, onRequestRejected) + .then(onRequestComplete, onRequestComplete); + + if (this.autoUpdate) { + this.scheduleNextUpdate(); + } + } + + private resolveReadyPromise(): void { + this.readyPromiseResolver(); + this.isReadyPromiseSettled = true; + } + + private rejectReadyPromise(err: Error): void { + this.readyPromiseRejecter(err); + this.isReadyPromiseSettled = true; + } + + private scheduleNextUpdate(): void { + const currentBackoffDelay = this.backoffController.getDelay(); + const nextUpdateDelay = Math.max(currentBackoffDelay, this.updateInterval); + logger.debug('Scheduling sync in %s ms', nextUpdateDelay); + this.currentTimeout = setTimeout(() => { + if (this.currentRequest) { + this.syncOnCurrentRequestComplete = true; + } else { + this.syncDatafile(); + } + }, nextUpdateDelay); + } + + private getNextDatafileFromResponse(response: Response): string { + logger.debug('Response status code: %s', response.statusCode); + if (typeof response.statusCode === 'undefined') { + return ''; + } + if (response.statusCode === 304) { + return ''; + } + if (isSuccessStatusCode(response.statusCode)) { + return response.body; + } + return ''; + } + + private trySavingLastModified(headers: Headers): void { + const lastModifiedHeader = headers['last-modified'] || headers['Last-Modified']; + if (typeof lastModifiedHeader !== 'undefined') { + this.lastResponseLastModified = lastModifiedHeader; + logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified); + } + } + + setDatafileFromCacheIfAvailable(): void { + this.cache.get(this.cacheKey).then(datafile => { + if (this.isStarted && !this.isReadyPromiseSettled && datafile !== '') { + logger.debug('Using datafile from cache'); + this.currentDatafile = datafile; + this.resolveReadyPromise(); + } + }); + } +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/index.browser.ts b/packages/optimizely-sdk/lib/datafile-manager/index.browser.ts new file mode 100644 index 000000000..00780202f --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/index.browser.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 * from './datafileManager'; +export { default as HttpPollingDatafileManager } from './browserDatafileManager'; diff --git a/packages/optimizely-sdk/lib/datafile-manager/index.node.ts b/packages/optimizely-sdk/lib/datafile-manager/index.node.ts new file mode 100644 index 000000000..7f75e1b25 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/index.node.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 * from './datafileManager'; +export { default as HttpPollingDatafileManager } from './nodeDatafileManager'; diff --git a/packages/optimizely-sdk/lib/datafile-manager/index.react_native.ts b/packages/optimizely-sdk/lib/datafile-manager/index.react_native.ts new file mode 100644 index 000000000..e6706908d --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/index.react_native.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2020, 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 + * + * http://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 * from './datafileManager'; +export { default as HttpPollingDatafileManager } from './reactNativeDatafileManager'; diff --git a/packages/optimizely-sdk/lib/datafile-manager/nodeDatafileManager.ts b/packages/optimizely-sdk/lib/datafile-manager/nodeDatafileManager.ts new file mode 100644 index 000000000..6c408dc08 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/nodeDatafileManager.ts @@ -0,0 +1,53 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); +import { makeGetRequest } from './nodeRequest'; +import HttpPollingDatafileManager from './httpPollingDatafileManager'; +import { Headers, AbortableRequest } from './http'; +import { NodeDatafileManagerConfig, DatafileManagerConfig } from './datafileManager'; +import { DEFAULT_URL_TEMPLATE, DEFAULT_AUTHENTICATED_URL_TEMPLATE } from './config'; + +const logger = getLogger('NodeDatafileManager'); + +export default class NodeDatafileManager extends HttpPollingDatafileManager { + private accessToken?: string; + + constructor(config: NodeDatafileManagerConfig) { + const defaultUrlTemplate = config.datafileAccessToken ? DEFAULT_AUTHENTICATED_URL_TEMPLATE : DEFAULT_URL_TEMPLATE; + super({ + ...config, + urlTemplate: config.urlTemplate || defaultUrlTemplate, + }); + this.accessToken = config.datafileAccessToken; + } + + protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + const requestHeaders = Object.assign({}, headers); + if (this.accessToken) { + logger.debug('Adding Authorization header with Bearer Token'); + requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; + } + return makeGetRequest(reqUrl, requestHeaders); + } + + protected getConfigDefaults(): Partial { + return { + autoUpdate: true, + }; + } +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/nodeRequest.ts b/packages/optimizely-sdk/lib/datafile-manager/nodeRequest.ts new file mode 100644 index 000000000..939bac536 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/nodeRequest.ts @@ -0,0 +1,154 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 http from 'http'; +import https from 'https'; +import url from 'url'; +import { Headers, AbortableRequest, Response } from './http'; +import { REQUEST_TIMEOUT_MS } from './config'; +import decompressResponse from 'decompress-response'; + +// Shared signature between http.request and https.request +type ClientRequestCreator = (options: http.RequestOptions) => http.ClientRequest; + +function getRequestOptionsFromUrl(url: url.UrlWithStringQuery): http.RequestOptions { + return { + hostname: url.hostname, + path: url.path, + port: url.port, + protocol: url.protocol, + }; +} + +/** + * Convert incomingMessage.headers (which has type http.IncomingHttpHeaders) into our Headers type defined in src/http.ts. + * + * Our Headers type is simplified and can't represent mutliple values for the same header name. + * + * We don't currently need multiple values support, and the consumer code becomes simpler if it can assume at-most 1 value + * per header name. + * + */ +function createHeadersFromNodeIncomingMessage(incomingMessage: http.IncomingMessage): Headers { + const headers: Headers = {}; + Object.keys(incomingMessage.headers).forEach(headerName => { + const headerValue = incomingMessage.headers[headerName]; + if (typeof headerValue === 'string') { + headers[headerName] = headerValue; + } else if (typeof headerValue === 'undefined') { + // no value provided for this header + } else { + // array + if (headerValue.length > 0) { + // We don't care about multiple values - just take the first one + headers[headerName] = headerValue[0]; + } + } + }); + return headers; +} + +function getResponseFromRequest(request: http.ClientRequest): Promise { + // TODO: When we drop support for Node 6, consider using util.promisify instead of + // constructing own Promise + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + request.abort(); + reject(new Error('Request timed out')); + }, REQUEST_TIMEOUT_MS); + + request.once('response', (incomingMessage: http.IncomingMessage) => { + if (request.aborted) { + return; + } + + const response = decompressResponse(incomingMessage); + + response.setEncoding('utf8'); + + let responseData = ''; + response.on('data', (chunk: string) => { + if (!request.aborted) { + responseData += chunk; + } + }); + + response.on('end', () => { + if (request.aborted) { + return; + } + + clearTimeout(timeout); + + resolve({ + statusCode: incomingMessage.statusCode, + body: responseData, + headers: createHeadersFromNodeIncomingMessage(incomingMessage), + }); + }); + }); + + request.on('error', (err: any) => { + clearTimeout(timeout); + + if (err instanceof Error) { + reject(err); + } else if (typeof err === 'string') { + reject(new Error(err)); + } else { + reject(new Error('Request error')); + } + }); + }); +} + +export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + // TODO: Use non-legacy URL parsing when we drop support for Node 6 + const parsedUrl = url.parse(reqUrl); + + let requester: ClientRequestCreator; + if (parsedUrl.protocol === 'http:') { + requester = http.request; + } else if (parsedUrl.protocol === 'https:') { + requester = https.request; + } else { + return { + responsePromise: Promise.reject(new Error(`Unsupported protocol: ${parsedUrl.protocol}`)), + abort(): void {}, + }; + } + + const requestOptions: http.RequestOptions = { + ...getRequestOptionsFromUrl(parsedUrl), + method: 'GET', + headers: { + ...headers, + 'accept-encoding': 'gzip,deflate', + }, + }; + + const request = requester(requestOptions); + const responsePromise = getResponseFromRequest(request); + + request.end(); + + return { + abort(): void { + request.abort(); + }, + responsePromise, + }; +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/persistentKeyValueCache.ts b/packages/optimizely-sdk/lib/datafile-manager/persistentKeyValueCache.ts new file mode 100644 index 000000000..08dfcf1fe --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/persistentKeyValueCache.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2020, 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 + * + * http://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. + */ + +/** + * An Interface to implement a persistent key value cache which supports strings as keys and values. + */ +export default interface PersistentKeyValueCache { + /** + * Returns value stored against a key or null if not found. + * @param key + * @returns + * Resolves promise with + * 1. string if value found was stored as a string. + * 2. null if the key does not exist in the cache. + * Rejects the promise in case of an error + */ + get(key: string): Promise; + + /** + * Stores string in the persistent cache against a key + * @param key + * @param val + * @returns + * Resolves promise without a value if successful + * Rejects the promise in case of an error + */ + set(key: string, val: string): Promise; + + /** + * Checks if a key exists in the cache + * @param key + * Resolves promise with + * 1. true if the key exists + * 2. false if the key does not exist + * Rejects the promise in case of an error + */ + contains(key: string): Promise; + + /** + * Removes the key value pair from cache. + * @param key + * Resolves promise without a value if successful + * Rejects the promise in case of an error + */ + remove(key: string): Promise; +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/reactNativeAsyncStorageCache.ts b/packages/optimizely-sdk/lib/datafile-manager/reactNativeAsyncStorageCache.ts new file mode 100644 index 000000000..d0a6b46d0 --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/reactNativeAsyncStorageCache.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2020, 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 + * + * http://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 AsyncStorage from '@react-native-community/async-storage'; + +import PersistentKeyValueCache from './persistentKeyValueCache'; + +export default class ReactNativeAsyncStorageCache implements PersistentKeyValueCache { + get(key: string): Promise { + return AsyncStorage.getItem(key).then((val: string | null) => { + if (!val) { + return ''; + } + return val; + }); + } + + set(key: string, val: string): Promise { + return AsyncStorage.setItem(key, val); + } + + contains(key: string): Promise { + return AsyncStorage.getItem(key).then((val: string | null) => val !== null); + } + + remove(key: string): Promise { + return AsyncStorage.removeItem(key); + } +} diff --git a/packages/optimizely-sdk/lib/datafile-manager/reactNativeDatafileManager.ts b/packages/optimizely-sdk/lib/datafile-manager/reactNativeDatafileManager.ts new file mode 100644 index 000000000..a7a2ac44e --- /dev/null +++ b/packages/optimizely-sdk/lib/datafile-manager/reactNativeDatafileManager.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2020, 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 + * + * http://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 { makeGetRequest } from './browserRequest'; +import HttpPollingDatafileManager from './httpPollingDatafileManager'; +import { Headers, AbortableRequest } from './http'; +import { DatafileManagerConfig } from './datafileManager'; +import ReactNativeAsyncStorageCache from './reactNativeAsyncStorageCache'; + +export default class ReactNativeDatafileManager extends HttpPollingDatafileManager { + protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + return makeGetRequest(reqUrl, headers); + } + + protected getConfigDefaults(): Partial { + return { + autoUpdate: true, + cache: new ReactNativeAsyncStorageCache(), + }; + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/eventDispatcher.ts b/packages/optimizely-sdk/lib/event-processor/eventDispatcher.ts new file mode 100644 index 000000000..28f9ae07c --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/eventDispatcher.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2019, 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 + * + * http://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 { EventV1 } from "./v1/buildEventV1"; + +export type EventDispatcherResponse = { + statusCode: number +} + +export type EventDispatcherCallback = (response: EventDispatcherResponse) => void + +export interface EventDispatcher { + dispatchEvent(event: EventV1Request, callback: EventDispatcherCallback): void +} + +export interface EventV1Request { + url: string + httpVerb: 'POST' | 'PUT' | 'GET' | 'PATCH' + params: EventV1, +} \ No newline at end of file diff --git a/packages/optimizely-sdk/lib/event-processor/eventProcessor.ts b/packages/optimizely-sdk/lib/event-processor/eventProcessor.ts new file mode 100644 index 000000000..beb98f082 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/eventProcessor.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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. + */ +// TODO change this to use Managed from js-sdk-models when available +import { Managed } from './managed' +import { ConversionEvent, ImpressionEvent } from './events' +import { EventV1Request } from './eventDispatcher' +import { EventQueue, DefaultEventQueue, SingleEventQueue } from './eventQueue' +// import { getLogger } from '@optimizely/js-sdk-logging' +var getLogger = require('../logging/logger'); + +// import { NOTIFICATION_TYPES, NotificationCenter } from '@optimizely/js-sdk-utils' +var NOTIFICATION_TYPES = require('../pkg-utils/index'); +var NotificationCenter = require('../pkg-utils/index'); +var NotificationCenter = require('../pkg-utils/index'); + +export const DEFAULT_FLUSH_INTERVAL = 30000 // Unit is ms - default flush interval is 30s +export const DEFAULT_BATCH_SIZE = 10 + +const logger = getLogger('EventProcessor') + +export type ProcessableEvent = ConversionEvent | ImpressionEvent + +export type EventDispatchResult = { result: boolean; event: ProcessableEvent } + +export interface EventProcessor extends Managed { + process(event: ProcessableEvent): void +} + +export function validateAndGetFlushInterval(flushInterval: number): number { + if (flushInterval <= 0) { + logger.warn( + `Invalid flushInterval ${flushInterval}, defaulting to ${DEFAULT_FLUSH_INTERVAL}`, + ) + flushInterval = DEFAULT_FLUSH_INTERVAL + } + return flushInterval +} + +export function validateAndGetBatchSize(batchSize: number): number { + batchSize = Math.floor(batchSize) + if (batchSize < 1) { + logger.warn( + `Invalid batchSize ${batchSize}, defaulting to ${DEFAULT_BATCH_SIZE}`, + ) + batchSize = DEFAULT_BATCH_SIZE + } + batchSize = Math.max(1, batchSize) + return batchSize +} + +export function getQueue(batchSize: number, flushInterval: number, sink: any, batchComparator: any): EventQueue { + let queue: EventQueue + if (batchSize > 1) { + queue = new DefaultEventQueue({ + flushInterval, + maxQueueSize: batchSize, + sink, + batchComparator, + }) + } else { + queue = new SingleEventQueue({ sink }) + } + return queue +} + +export function sendEventNotification(notificationCenter: typeof NotificationCenter | undefined, event: EventV1Request): void { + if (notificationCenter) { + notificationCenter.sendNotifications( + NOTIFICATION_TYPES.LOG_EVENT, + event, + ) + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/eventQueue.ts b/packages/optimizely-sdk/lib/event-processor/eventQueue.ts new file mode 100644 index 000000000..72b636080 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/eventQueue.ts @@ -0,0 +1,161 @@ +/** + * Copyright 2019, 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 + * + * http://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 { getLogger } from '@optimizely/js-sdk-logging' +var getLogger = require('../logging/logger'); + +// TODO change this to use Managed from js-sdk-models when available +import { Managed } from './managed' + +const logger = getLogger('EventProcessor') + +export type EventQueueSink = (buffer: K[]) => Promise + +export interface EventQueue extends Managed { + enqueue(event: K): void +} + +export interface EventQueueFactory { + createEventQueue(config: { + sink: EventQueueSink + flushInterval: number + maxQueueSize: number + }): EventQueue +} + +class Timer { + private timeout: number + private callback: () => void + private timeoutId?: number + + constructor({ timeout, callback }: { timeout: number; callback: () => void }) { + this.timeout = Math.max(timeout, 0) + this.callback = callback + } + + start(): void { + this.timeoutId = setTimeout(this.callback, this.timeout) as any + } + + refresh(): void { + this.stop() + this.start() + } + + stop(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId as any) + } + } +} + +export class SingleEventQueue implements EventQueue { + private sink: EventQueueSink + + constructor({ sink }: { sink: EventQueueSink }) { + this.sink = sink + } + + start(): void { + // no-op + } + + stop(): Promise { + // no-op + return Promise.resolve() + } + + enqueue(event: K): void { + this.sink([event]) + } +} + +export class DefaultEventQueue implements EventQueue { + // expose for testing + public timer: Timer + private buffer: K[] + private maxQueueSize: number + private sink: EventQueueSink + // batchComparator is called to determine whether two events can be included + // together in the same batch + private batchComparator: (eventA: K, eventB: K) => boolean + private started: boolean + + constructor({ + flushInterval, + maxQueueSize, + sink, + batchComparator, + }: { + flushInterval: number + maxQueueSize: number + sink: EventQueueSink + batchComparator: (eventA: K, eventB: K) => boolean + }) { + this.buffer = [] + this.maxQueueSize = Math.max(maxQueueSize, 1) + this.sink = sink + this.batchComparator = batchComparator + this.timer = new Timer({ + callback: this.flush.bind(this), + timeout: flushInterval, + }) + this.started = false + } + + start(): void { + this.started = true + // dont start the timer until the first event is enqueued + } + + stop(): Promise { + this.started = false + const result = this.sink(this.buffer) + this.buffer = [] + this.timer.stop() + return result + } + + enqueue(event: K): void { + if (!this.started) { + logger.warn('Queue is stopped, not accepting event') + return + } + + // If new event cannot be included into the current batch, flush so it can + // be in its own new batch. + const bufferedEvent: K | undefined = this.buffer[0] + if (bufferedEvent && !this.batchComparator(bufferedEvent, event)) { + this.flush() + } + + // start the timer when the first event is put in + if (this.buffer.length === 0) { + this.timer.refresh() + } + this.buffer.push(event) + + if (this.buffer.length >= this.maxQueueSize) { + this.flush() + } + } + + flush() { + this.sink(this.buffer) + this.buffer = [] + this.timer.stop() + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/events.ts b/packages/optimizely-sdk/lib/event-processor/events.ts new file mode 100644 index 000000000..2285ec859 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/events.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2019, 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 + * + * http://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 VisitorAttribute = { + entityId: string + key: string + value: string | number | boolean +} + +export interface BaseEvent { + type: 'impression' | 'conversion' + timestamp: number + uuid: string + + // projectConfig stuff + context: { + accountId: string + projectId: string + clientName: string + clientVersion: string + revision: string + anonymizeIP: boolean + botFiltering?: boolean + } +} + +export interface ImpressionEvent extends BaseEvent { + type: 'impression' + + user: { + id: string + attributes: VisitorAttribute[] + } + + layer: { + id: string + } | null + + experiment: { + id: string + key: string + } | null + + variation: { + id: string + key: string + } | null +} + +export interface ConversionEvent extends BaseEvent { + type: 'conversion' + + user: { + id: string + attributes: VisitorAttribute[] + } + + event: { + id: string + key: string + } + + revenue: number | null + value: number | null + tags: EventTags +} + +export type EventTags = { + [key: string]: string | number | null +} + +export function areEventContextsEqual(eventA: BaseEvent, eventB: BaseEvent): boolean { + const contextA = eventA.context + const contextB = eventB.context + return ( + contextA.accountId === contextB.accountId && + contextA.projectId === contextB.projectId && + contextA.clientName === contextB.clientName && + contextA.clientVersion === contextB.clientVersion && + contextA.revision === contextB.revision && + contextA.anonymizeIP === contextB.anonymizeIP && + contextA.botFiltering === contextB.botFiltering + ) +} diff --git a/packages/optimizely-sdk/lib/event-processor/index.react_native.ts b/packages/optimizely-sdk/lib/event-processor/index.react_native.ts new file mode 100644 index 000000000..2e3ac34e4 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/index.react_native.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2020, 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 + * + * http://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 * from './events' +export * from './eventProcessor' +export * from './eventDispatcher' +export * from './managed' +export * from './pendingEventsDispatcher' +export * from './v1/buildEventV1' +export * from './v1/v1EventProcessor.react_native' diff --git a/packages/optimizely-sdk/lib/event-processor/index.ts b/packages/optimizely-sdk/lib/event-processor/index.ts new file mode 100644 index 000000000..d2f916615 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 * from './events' +export * from './eventProcessor' +export * from './eventDispatcher' +export * from './managed' +export * from './pendingEventsDispatcher' +export * from './v1/buildEventV1' +export * from './v1/v1EventProcessor' diff --git a/packages/optimizely-sdk/lib/event-processor/managed.ts b/packages/optimizely-sdk/lib/event-processor/managed.ts new file mode 100644 index 000000000..686d2aa0f --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/managed.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2019, 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 + * + * http://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 interface Managed { + start(): void + + stop(): Promise +} diff --git a/packages/optimizely-sdk/lib/event-processor/pendingEventsDispatcher.ts b/packages/optimizely-sdk/lib/event-processor/pendingEventsDispatcher.ts new file mode 100644 index 000000000..380db4e34 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/pendingEventsDispatcher.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2019, 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 + * + * http://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 { getLogger } from '@optimizely/js-sdk-logging' +var getLogger = require('../logging/logger'); + +import { EventDispatcher, EventV1Request, EventDispatcherCallback } from './eventDispatcher' +import { PendingEventsStore, LocalStorageStore } from './pendingEventsStore' +// import { generateUUID, getTimestamp } from '@optimizely/js-sdk-utils' +var generateUUID = require('../pkg-utils/index'); +var getTimestamp = require('../pkg-utils/index'); + +const logger = getLogger('EventProcessor') + +export type DispatcherEntry = { + uuid: string + timestamp: number + request: EventV1Request +} + +export class PendingEventsDispatcher implements EventDispatcher { + protected dispatcher: EventDispatcher + protected store: PendingEventsStore + + constructor({ + eventDispatcher, + store, + }: { + eventDispatcher: EventDispatcher + store: PendingEventsStore + }) { + this.dispatcher = eventDispatcher + this.store = store + } + + dispatchEvent(request: EventV1Request, callback: EventDispatcherCallback): void { + this.send( + { + uuid: generateUUID(), + timestamp: getTimestamp(), + request, + }, + callback, + ) + } + + sendPendingEvents(): void { + const pendingEvents = this.store.values() + + logger.debug('Sending %s pending events from previous page', pendingEvents.length) + + pendingEvents.forEach(item => { + try { + this.send(item, () => {}) + } catch (e) {} + }) + } + + protected send(entry: DispatcherEntry, callback: EventDispatcherCallback): void { + this.store.set(entry.uuid, entry) + + this.dispatcher.dispatchEvent(entry.request, response => { + this.store.remove(entry.uuid) + callback(response) + }) + } +} + +export class LocalStoragePendingEventsDispatcher extends PendingEventsDispatcher { + constructor({ eventDispatcher }: { eventDispatcher: EventDispatcher }) { + super({ + eventDispatcher, + store: new LocalStorageStore({ + // TODO make this configurable + maxValues: 100, + key: 'fs_optly_pending_events', + }), + }) + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/pendingEventsStore.ts b/packages/optimizely-sdk/lib/event-processor/pendingEventsStore.ts new file mode 100644 index 000000000..ce5ff3877 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/pendingEventsStore.ts @@ -0,0 +1,120 @@ +/** + * Copyright 2019, 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 + * + * http://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 { objectValues } from '@optimizely/js-sdk-utils' +var objectValues = require('../pkg-utils/index'); + +// import { getLogger } from '@optimizely/js-sdk-logging'; +var getLogger = require('../logging/logger'); + +const logger = getLogger('EventProcessor') + +export interface PendingEventsStore { + get(key: string): K | null + + set(key: string, value: K): void + + remove(key: string): void + + values(): K[] + + clear(): void + + replace(newMap: { [key: string]: K }): void +} + +interface StoreEntry { + uuid: string + timestamp: number +} + +export class LocalStorageStore implements PendingEventsStore { + protected LS_KEY: string + protected maxValues: number + + constructor({ key, maxValues = 1000 }: { key: string; maxValues?: number }) { + this.LS_KEY = key + this.maxValues = maxValues + } + + get(key: string): K | null { + return this.getMap()[key] || null + } + + set(key: string, value: K): void { + const map = this.getMap() + map[key] = value + this.replace(map) + } + + remove(key: string): void { + const map = this.getMap() + delete map[key] + this.replace(map) + } + + values(): K[] { + return objectValues(this.getMap()) + } + + clear(): void { + this.replace({}) + } + + replace(map: { [key: string]: K }): void { + try { + // This is a temporary fix to support React Native which does not have localStorage. + window.localStorage && localStorage.setItem(this.LS_KEY, JSON.stringify(map)) + this.clean() + } catch (e) { + logger.error(e) + } + } + + private clean() { + const map = this.getMap() + const keys = Object.keys(map) + const toRemove = keys.length - this.maxValues + if (toRemove < 1) { + return + } + + const entries = keys.map(key => ({ + key, + value: map[key] + })) + + entries.sort((a, b) => a.value.timestamp - b.value.timestamp) + + for (var i = 0; i < toRemove; i++) { + delete map[entries[i].key] + } + + this.replace(map) + } + + private getMap(): { [key: string]: K } { + try { + // This is a temporary fix to support React Native which does not have localStorage. + const data = window.localStorage && localStorage.getItem(this.LS_KEY); + if (data) { + return (JSON.parse(data) as { [key: string]: K }) || {} + } + } catch (e) { + logger.error(e) + } + return {} + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/persistentKeyValueCache.ts b/packages/optimizely-sdk/lib/event-processor/persistentKeyValueCache.ts new file mode 100644 index 000000000..9b3ef9fac --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/persistentKeyValueCache.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2020, 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 + * + * http://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. + */ + +/** + * An Interface to implement a persistent key value cache which supports strings as keys + * and JSON Object as value. + */ +export default interface PersistentKeyValueCache { + /** + * Returns value stored against a key or null if not found. + * @param key + * @returns + * Resolves promise with + * 1. Object if value found was stored as a JSON Object. + * 2. null if the key does not exist in the cache. + * Rejects the promise in case of an error + */ + get(key: string): Promise; + + /** + * Stores Object in the persistent cache against a key + * @param key + * @param val + * @returns + * Resolves promise without a value if successful + * Rejects the promise in case of an error + */ + set(key: string, val: any): Promise; + + /** + * Checks if a key exists in the cache + * @param key + * Resolves promise with + * 1. true if the key exists + * 2. false if the key does not exist + * Rejects the promise in case of an error + */ + contains(key: string): Promise; + + /** + * Removes the key value pair from cache. + * @param key + * Resolves promise without a value if successful + * Rejects the promise in case of an error + */ + remove(key: string): Promise; +} diff --git a/packages/optimizely-sdk/lib/event-processor/reactNativeAsyncStorageCache.ts b/packages/optimizely-sdk/lib/event-processor/reactNativeAsyncStorageCache.ts new file mode 100644 index 000000000..7f488617a --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/reactNativeAsyncStorageCache.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2020, 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 + * + * http://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 AsyncStorage from '@react-native-community/async-storage'; + +import PersistentKeyValueCache from './persistentKeyValueCache'; + +export default class ReactNativeAsyncStorageCache implements PersistentKeyValueCache { + get(key: string): Promise { + return AsyncStorage.getItem(key).then((val: string | null) => { + if (!val) { + return null; + } + try { + return JSON.parse(val); + } catch (ex) { + throw ex; + } + }); + } + + set(key: string, val: any): Promise { + try { + return AsyncStorage.setItem(key, JSON.stringify(val)); + } catch (ex) { + return Promise.reject(ex); + } + } + + contains(key: string): Promise { + return AsyncStorage.getItem(key).then((val: string | null) => val !== null); + } + + remove(key: string): Promise { + return AsyncStorage.removeItem(key); + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/reactNativeEventsStore.ts b/packages/optimizely-sdk/lib/event-processor/reactNativeEventsStore.ts new file mode 100644 index 000000000..f44563e63 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/reactNativeEventsStore.ts @@ -0,0 +1,84 @@ + +/** + * Copyright 2020, 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 + * + * http://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 { getLogger } from '@optimizely/js-sdk-logging' +var getLogger = require('../logging/logger'); + +// import { objectValues } from "@optimizely/js-sdk-utils" +var objectValues = require('../pkg-utils/index'); + +import { Synchronizer } from './synchronizer' +import ReactNativeAsyncStorageCache from './reactNativeAsyncStorageCache' + +const logger = getLogger('ReactNativeEventsStore') + +/** + * A key value store which stores objects of type T with string keys + */ +export class ReactNativeEventsStore { + private maxSize: number + private storeKey: string + private synchronizer: Synchronizer = new Synchronizer() + private cache: ReactNativeAsyncStorageCache = new ReactNativeAsyncStorageCache() + + constructor(maxSize: number, storeKey: string) { + this.maxSize = maxSize + this.storeKey = storeKey + } + + public async set(key: string, event: T): Promise { + await this.synchronizer.getLock() + const eventsMap: {[key: string]: T} = await this.cache.get(this.storeKey) || {} + if (Object.keys(eventsMap).length < this.maxSize) { + eventsMap[key] = event + await this.cache.set(this.storeKey, eventsMap) + } else { + logger.warn('React native events store is full. Store key: %s', this.storeKey) + } + this.synchronizer.releaseLock() + return key + } + + public async get(key: string): Promise { + await this.synchronizer.getLock() + const eventsMap: {[key: string]: T} = await this.cache.get(this.storeKey) || {} + this.synchronizer.releaseLock() + return eventsMap[key] + } + + public async getEventsMap(): Promise<{[key: string]: T}> { + return await this.cache.get(this.storeKey) || {} + } + + public async getEventsList(): Promise { + await this.synchronizer.getLock() + const eventsMap: {[key: string]: T} = await this.cache.get(this.storeKey) || {} + this.synchronizer.releaseLock() + return objectValues(eventsMap) + } + + public async remove(key: string): Promise { + await this.synchronizer.getLock() + const eventsMap: {[key: string]: T} = await this.cache.get(this.storeKey) || {} + eventsMap[key] && delete eventsMap[key] + await this.cache.set(this.storeKey, eventsMap) + this.synchronizer.releaseLock() + } + + public async clear(): Promise { + await this.cache.remove(this.storeKey) + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/requestTracker.ts b/packages/optimizely-sdk/lib/event-processor/requestTracker.ts new file mode 100644 index 000000000..e3f774690 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/requestTracker.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2020, 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 + * + * http://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. + */ + +/** + * RequestTracker keeps track of in-flight requests for EventProcessor using + * an internal counter. It exposes methods for adding a new request to be + * tracked, and getting a Promise representing the completion of currently + * tracked requests. + */ +class RequestTracker { + private reqsInFlightCount: number = 0 + private reqsCompleteResolvers: Array<() => void> = [] + + /** + * Track the argument request (represented by a Promise). reqPromise will feed + * into the state of Promises returned by onRequestsComplete. + * @param {Promise} reqPromise + */ + public trackRequest(reqPromise: Promise): void { + this.reqsInFlightCount++ + const onReqComplete = () => { + this.reqsInFlightCount-- + if (this.reqsInFlightCount === 0) { + this.reqsCompleteResolvers.forEach(resolver => resolver()) + this.reqsCompleteResolvers = [] + } + } + reqPromise.then(onReqComplete, onReqComplete) + } + + /** + * Return a Promise that fulfills after all currently-tracked request promises + * are resolved. + * @return {Promise} + */ + public onRequestsComplete(): Promise { + return new Promise(resolve => { + if (this.reqsInFlightCount === 0) { + resolve() + } else { + this.reqsCompleteResolvers.push(resolve) + } + }) + } +} + +export default RequestTracker diff --git a/packages/optimizely-sdk/lib/event-processor/synchronizer.ts b/packages/optimizely-sdk/lib/event-processor/synchronizer.ts new file mode 100644 index 000000000..2d5d861f2 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/synchronizer.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2020, 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 + * + * http://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. + */ + +/** + * This synchronizer makes sure the operations are atomic using promises. + */ +export class Synchronizer { + private lockPromises: Promise[] = [] + private resolvers: any[] = [] + + // Adds a promise to the existing list and returns the promise so that the code block can wait for its turn + public async getLock(): Promise { + this.lockPromises.push(new Promise(resolve => this.resolvers.push(resolve))) + if (this.lockPromises.length === 1) { + return + } + await this.lockPromises[this.lockPromises.length - 2] + } + + // Resolves first promise in the array so that the code block waiting on the first promise can continue execution + public releaseLock(): void { + if (this.lockPromises.length > 0) { + this.lockPromises.shift() + const resolver = this.resolvers.shift() + resolver() + return + } + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/v1/buildEventV1.ts b/packages/optimizely-sdk/lib/event-processor/v1/buildEventV1.ts new file mode 100644 index 000000000..5dd65befd --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/v1/buildEventV1.ts @@ -0,0 +1,238 @@ +import { EventTags, ConversionEvent, ImpressionEvent, VisitorAttribute } from '../events' +import { ProcessableEvent } from '../eventProcessor' +import { EventV1Request } from '../eventDispatcher' + +const ACTIVATE_EVENT_KEY = 'campaign_activated' +const CUSTOM_ATTRIBUTE_FEATURE_TYPE = 'custom' +const BOT_FILTERING_KEY = '$opt_bot_filtering' + +export type EventV1 = { + account_id: string + project_id: string + revision: string + client_name: string + client_version: string + anonymize_ip: boolean + enrich_decisions: boolean + visitors: Visitor[] +} + +type Visitor = { + snapshots: Visitor.Snapshot[] + visitor_id: string + attributes: Visitor.Attribute[] +} + +namespace Visitor { + type AttributeType = 'custom' + + export type Attribute = { + // attribute id + entity_id: string + // attribute key + key: string + type: AttributeType + value: string | number | boolean + } + + export type Snapshot = { + decisions?: Decision[] + events: SnapshotEvent[] + } + + type Decision = { + campaign_id: string | null + experiment_id: string | null + variation_id: string | null + } + + export type SnapshotEvent = { + entity_id: string | null + timestamp: number + uuid: string + key: string + revenue?: number + value?: number + tags?: EventTags + } +} + + + +type Attributes = { + [key: string]: string | number | boolean +} + +/** + * Given an array of batchable Decision or ConversionEvent events it returns + * a single EventV1 with proper batching + * + * @param {ProcessableEvent[]} events + * @returns {EventV1} + */ +export function makeBatchedEventV1(events: ProcessableEvent[]): EventV1 { + const visitors: Visitor[] = [] + const data = events[0] + + events.forEach(event => { + if (event.type === 'conversion' || event.type === 'impression') { + let visitor = makeVisitor(event) + + if (event.type === 'impression') { + visitor.snapshots.push(makeDecisionSnapshot(event)) + } else if (event.type === 'conversion') { + visitor.snapshots.push(makeConversionSnapshot(event)) + } + + visitors.push(visitor) + } + }) + + return { + client_name: data.context.clientName, + client_version: data.context.clientVersion, + + account_id: data.context.accountId, + project_id: data.context.projectId, + revision: data.context.revision, + anonymize_ip: data.context.anonymizeIP, + enrich_decisions: true, + + visitors, + } +} + +function makeConversionSnapshot(conversion: ConversionEvent): Visitor.Snapshot { + let tags: EventTags = { + ...conversion.tags, + } + + delete tags['revenue'] + delete tags['value'] + + const event: Visitor.SnapshotEvent = { + entity_id: conversion.event.id, + key: conversion.event.key, + timestamp: conversion.timestamp, + uuid: conversion.uuid, + } + + if (conversion.tags) { + event.tags = conversion.tags + } + + if (conversion.value != null) { + event.value = conversion.value + } + + if (conversion.revenue != null) { + event.revenue = conversion.revenue + } + + return { + events: [event], + } +} + +function makeDecisionSnapshot(event: ImpressionEvent): Visitor.Snapshot { + const { layer, experiment, variation } = event + let layerId = layer ? layer.id : null + let experimentId = experiment ? experiment.id : null + let variationId = variation ? variation.id : null + + return { + decisions: [ + { + campaign_id: layerId, + experiment_id: experimentId, + variation_id: variationId, + }, + ], + events: [ + { + entity_id: layerId, + timestamp: event.timestamp, + key: ACTIVATE_EVENT_KEY, + uuid: event.uuid, + }, + ], + } +} + +function makeVisitor(data: ImpressionEvent | ConversionEvent): Visitor { + const visitor: Visitor = { + snapshots: [], + visitor_id: data.user.id, + attributes: [], + } + + data.user.attributes.forEach(attr => { + visitor.attributes.push({ + entity_id: attr.entityId, + key: attr.key, + type: 'custom' as 'custom', // tell the compiler this is always string "custom" + value: attr.value, + }) + }) + + if (typeof data.context.botFiltering === 'boolean') { + visitor.attributes.push({ + entity_id: BOT_FILTERING_KEY, + key: BOT_FILTERING_KEY, + type: CUSTOM_ATTRIBUTE_FEATURE_TYPE, + value: data.context.botFiltering, + }) + } + return visitor +} + +/** + * Event for usage with v1 logtier + * + * @export + * @interface EventBuilderV1 + */ + +export function buildImpressionEventV1(data: ImpressionEvent): EventV1 { + const visitor = makeVisitor(data) + visitor.snapshots.push(makeDecisionSnapshot(data)) + + return { + client_name: data.context.clientName, + client_version: data.context.clientVersion, + + account_id: data.context.accountId, + project_id: data.context.projectId, + revision: data.context.revision, + anonymize_ip: data.context.anonymizeIP, + enrich_decisions: true, + + visitors: [visitor], + } +} + +export function buildConversionEventV1(data: ConversionEvent): EventV1 { + const visitor = makeVisitor(data) + visitor.snapshots.push(makeConversionSnapshot(data)) + + return { + client_name: data.context.clientName, + client_version: data.context.clientVersion, + + account_id: data.context.accountId, + project_id: data.context.projectId, + revision: data.context.revision, + anonymize_ip: data.context.anonymizeIP, + enrich_decisions: true, + + visitors: [visitor], + } +} + +export function formatEvents(events: ProcessableEvent[]): EventV1Request { + return { + url: 'https://logx.optimizely.com/v1/events', + httpVerb: 'POST', + params: makeBatchedEventV1(events), + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/v1/v1EventProcessor.react_native.ts b/packages/optimizely-sdk/lib/event-processor/v1/v1EventProcessor.react_native.ts new file mode 100644 index 000000000..d07ffa7eb --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/v1/v1EventProcessor.react_native.ts @@ -0,0 +1,240 @@ +/** + * Copyright 2020, 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 + * + * http://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 { +// generateUUID, +// NotificationCenter, +// objectEntries, +// } from '@optimizely/js-sdk-utils' +var generateUUID = require('../../pkg-utils/index'); +var NotificationCenter = require('../../pkg-utils/index'); +var objectEntries = require('../../pkg-utils/index'); + +import { + NetInfoState, + addEventListener as addConnectionListener, +} from "@react-native-community/netinfo" +// import { getLogger } from '@optimizely/js-sdk-logging' +var getLogger = require('../logging/logger'); + +import { + getQueue, + EventProcessor, + ProcessableEvent, + sendEventNotification, + validateAndGetBatchSize, + validateAndGetFlushInterval, + DEFAULT_BATCH_SIZE, + DEFAULT_FLUSH_INTERVAL, +} from "../eventProcessor" +import { ReactNativeEventsStore } from '../reactNativeEventsStore' +import { Synchronizer } from '../synchronizer' +import { EventQueue } from '../eventQueue' +import RequestTracker from '../requestTracker' +import { areEventContextsEqual } from '../events' +import { formatEvents } from './buildEventV1' +import { + EventV1Request, + EventDispatcher, + EventDispatcherResponse, +} from '../eventDispatcher' + +const logger = getLogger('ReactNativeEventProcessor') + +const DEFAULT_MAX_QUEUE_SIZE = 10000 +const PENDING_EVENTS_STORE_KEY = 'fs_optly_pending_events' +const EVENT_BUFFER_STORE_KEY = 'fs_optly_event_buffer' + +/** + * React Native Events Processor with Caching support for events when app is offline. + */ +export class LogTierV1EventProcessor implements EventProcessor { + private dispatcher: EventDispatcher + // expose for testing + public queue: EventQueue + private notificationCenter?: typeof NotificationCenter + private requestTracker: RequestTracker + + private unsubscribeNetInfo: Function | null = null + private isInternetReachable: boolean = true + private pendingEventsPromise: Promise | null = null + private synchronizer: Synchronizer = new Synchronizer() + + // If a pending event fails to dispatch, this indicates skipping further events to preserve sequence in the next retry. + private shouldSkipDispatchToPreserveSequence: boolean = false + + /** + * This Stores Formatted events before dispatching. The events are removed after they are successfully dispatched. + * Stored events are retried on every new event dispatch, when connection becomes available again or when SDK initializes the next time. + */ + private pendingEventsStore: ReactNativeEventsStore + + /** + * This stores individual events generated from the SDK till they are part of the pending buffer. + * The store is cleared right before the event is formatted to be dispatched. + * This is to make sure that individual events are not lost when app closes before the buffer was flushed. + */ + private eventBufferStore: ReactNativeEventsStore + + constructor({ + dispatcher, + flushInterval = DEFAULT_FLUSH_INTERVAL, + batchSize = DEFAULT_BATCH_SIZE, + maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, + notificationCenter, + }: { + dispatcher: EventDispatcher + flushInterval?: number + batchSize?: number + maxQueueSize?: number + notificationCenter?: typeof NotificationCenter + }) { + this.dispatcher = dispatcher + this.notificationCenter = notificationCenter + this.requestTracker = new RequestTracker() + + flushInterval = validateAndGetFlushInterval(flushInterval) + batchSize = validateAndGetBatchSize(batchSize) + this.queue = getQueue(batchSize, flushInterval, this.drainQueue.bind(this), areEventContextsEqual) + this.pendingEventsStore = new ReactNativeEventsStore(maxQueueSize, PENDING_EVENTS_STORE_KEY) + this.eventBufferStore = new ReactNativeEventsStore(maxQueueSize, EVENT_BUFFER_STORE_KEY) + } + + private async connectionListener(state: NetInfoState) { + if (this.isInternetReachable && !state.isInternetReachable) { + this.isInternetReachable = false + logger.debug('Internet connection lost') + return + } + if (!this.isInternetReachable && state.isInternetReachable) { + this.isInternetReachable = true + logger.debug('Internet connection is restored, attempting to dispatch pending events') + await this.processPendingEvents() + this.shouldSkipDispatchToPreserveSequence = false + } + } + + private isSuccessResponse(status: number): boolean { + return status >= 200 && status < 400 + } + + private async drainQueue(buffer: ProcessableEvent[]): Promise { + if (buffer.length === 0) { + return + } + + await this.synchronizer.getLock() + + // Retry pending failed events while draining queue + await this.processPendingEvents() + + logger.debug('draining queue with %s events', buffer.length) + + const eventCacheKey = generateUUID() + const formattedEvent = formatEvents(buffer) + + // Store formatted event before dispatching to be retried later in case of failure. + await this.pendingEventsStore.set(eventCacheKey, formattedEvent) + + // Clear buffer because the buffer has become a formatted event and is already stored in pending cache. + for (const {uuid} of buffer) { + await this.eventBufferStore.remove(uuid) + } + + if (!this.shouldSkipDispatchToPreserveSequence) { + await this.dispatchEvent(eventCacheKey, formattedEvent) + } + + // Resetting skip flag because current sequence of events have all been processed + this.shouldSkipDispatchToPreserveSequence = false + + this.synchronizer.releaseLock() + } + + private async processPendingEvents(): Promise { + logger.debug('Processing pending events from offline storage') + if (!this.pendingEventsPromise) { + // Only process events if existing promise is not in progress + this.pendingEventsPromise = this.getPendingEventsPromise() + } else { + logger.debug('Already processing pending events, returning the existing promise') + } + await this.pendingEventsPromise + this.pendingEventsPromise = null + } + + private async getPendingEventsPromise(): Promise { + const formattedEvents: {[key: string]: any} = await this.pendingEventsStore.getEventsMap() + const eventEntries = objectEntries(formattedEvents) + logger.debug('Processing %s pending events', eventEntries.length) + // Using for loop to be able to wait for previous dispatch to finish before moving on to the new one + for (const [eventKey, event] of eventEntries) { + // If one event dispatch failed, skip subsequent events to preserve sequence + if (this.shouldSkipDispatchToPreserveSequence) { + return + } + await this.dispatchEvent(eventKey, event) + } + } + + private async dispatchEvent(eventCacheKey: string, event: EventV1Request): Promise { + const requestPromise = new Promise((resolve) => { + this.dispatcher.dispatchEvent(event, async ({ statusCode }: EventDispatcherResponse) => { + if (this.isSuccessResponse(statusCode)) { + await this.pendingEventsStore.remove(eventCacheKey) + } else { + this.shouldSkipDispatchToPreserveSequence = true + logger.warn('Failed to dispatch event, Response status Code: %s', statusCode) + } + resolve() + }) + sendEventNotification(this.notificationCenter, event) + }) + // Tracking all the requests to dispatch to make sure request is completed before fulfilling the `stop` promise + this.requestTracker.trackRequest(requestPromise) + return requestPromise + } + + public async start(): Promise { + this.queue.start() + this.unsubscribeNetInfo = addConnectionListener(this.connectionListener.bind(this)) + + await this.processPendingEvents() + this.shouldSkipDispatchToPreserveSequence = false + + // Process individual events pending from the buffer. + const events: ProcessableEvent[] = await this.eventBufferStore.getEventsList() + await this.eventBufferStore.clear() + events.forEach(this.process.bind(this)) + } + + public process(event: ProcessableEvent): void { + // Adding events to buffer store. If app closes before dispatch, we can reprocess next time the app initializes + this.eventBufferStore.set(event.uuid, event).then(() => { + this.queue.enqueue(event) + }) + } + + public async stop(): Promise { + // swallow - an error stopping this queue shouldn't prevent this from stopping + try { + this.unsubscribeNetInfo && this.unsubscribeNetInfo() + await this.queue.stop() + return this.requestTracker.onRequestsComplete() + } catch (e) { + logger.error('Error stopping EventProcessor: "%s"', e.message, e) + } + } +} diff --git a/packages/optimizely-sdk/lib/event-processor/v1/v1EventProcessor.ts b/packages/optimizely-sdk/lib/event-processor/v1/v1EventProcessor.ts new file mode 100644 index 000000000..ac2a18089 --- /dev/null +++ b/packages/optimizely-sdk/lib/event-processor/v1/v1EventProcessor.ts @@ -0,0 +1,103 @@ +/** + * Copyright 2019-2020, 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 + * + * http://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 { getLogger } from '@optimizely/js-sdk-logging' +var getLogger = require('../logging/logger'); + +// import { NotificationCenter } from '@optimizely/js-sdk-utils' +var NotificationCenter = require('../../pkg-utils/index'); + +import { EventDispatcher } from '../eventDispatcher' +import { + getQueue, + EventProcessor, + ProcessableEvent, + sendEventNotification, + validateAndGetBatchSize, + validateAndGetFlushInterval, + DEFAULT_BATCH_SIZE, + DEFAULT_FLUSH_INTERVAL, +} from '../eventProcessor' +import { EventQueue } from '../eventQueue' +import RequestTracker from '../requestTracker' +import { areEventContextsEqual } from '../events' +import { formatEvents } from './buildEventV1' + +const logger = getLogger('LogTierV1EventProcessor') + +export class LogTierV1EventProcessor implements EventProcessor { + private dispatcher: EventDispatcher + private queue: EventQueue + private notificationCenter?: typeof NotificationCenter + private requestTracker: RequestTracker + + constructor({ + dispatcher, + flushInterval = DEFAULT_FLUSH_INTERVAL, + batchSize = DEFAULT_BATCH_SIZE, + notificationCenter, + }: { + dispatcher: EventDispatcher + flushInterval?: number + batchSize?: number + notificationCenter?: typeof NotificationCenter + }) { + this.dispatcher = dispatcher + this.notificationCenter = notificationCenter + this.requestTracker = new RequestTracker() + + flushInterval = validateAndGetFlushInterval(flushInterval) + batchSize = validateAndGetBatchSize(batchSize) + this.queue = getQueue(batchSize, flushInterval, this.drainQueue.bind(this), areEventContextsEqual) + } + + drainQueue(buffer: ProcessableEvent[]): Promise { + const reqPromise = new Promise(resolve => { + logger.debug('draining queue with %s events', buffer.length) + + if (buffer.length === 0) { + resolve() + return + } + + const formattedEvent = formatEvents(buffer) + this.dispatcher.dispatchEvent(formattedEvent, () => { + resolve() + }) + sendEventNotification(this.notificationCenter, formattedEvent) + }) + this.requestTracker.trackRequest(reqPromise) + return reqPromise + } + + process(event: ProcessableEvent): void { + this.queue.enqueue(event) + } + + stop(): Promise { + // swallow - an error stopping this queue shouldn't prevent this from stopping + try { + this.queue.stop() + return this.requestTracker.onRequestsComplete() + } catch (e) { + logger.error('Error stopping EventProcessor: "%s"', e.message, e) + } + return Promise.resolve() + } + + async start(): Promise { + this.queue.start() + } +} diff --git a/packages/optimizely-sdk/lib/index.browser.js b/packages/optimizely-sdk/lib/index.browser.js index c368cef8b..445432c98 100644 --- a/packages/optimizely-sdk/lib/index.browser.js +++ b/packages/optimizely-sdk/lib/index.browser.js @@ -13,15 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - getLogger, - setLogHandler, - setLogLevel, - setErrorHandler, - getErrorHandler, - LogLevel, -} from '@optimizely/js-sdk-logging'; -import { LocalStoragePendingEventsDispatcher } from '@optimizely/js-sdk-event-processor'; +// import { +// getLogger, +// setLogHandler, +// setLogLevel, +// setErrorHandler, +// getErrorHandler, +// LogLevel, +// } from '@optimizely/js-sdk-logging'; +var getLogger = require('./logging/logger') +var setLogHandler = require('./logging/logger') +var setLogLevel = require('./logging/logger') +var setErrorHandler = require('./logging/errorHandler') +var getErrorHandler = require('./logging/errorHandler') +var LogLevel = require('./logging/models') + +// import { LocalStoragePendingEventsDispatcher } from '@optimizely/js-sdk-event-processor'; +var LocalStoragePendingEventsDispatcher = require('./event-processor/pendingEventsDispatcher'); import fns from './utils/fns'; import configValidator from './utils/config_validator'; diff --git a/packages/optimizely-sdk/lib/index.browser.tests.js b/packages/optimizely-sdk/lib/index.browser.tests.js index a8feeb7cc..91f46e30b 100644 --- a/packages/optimizely-sdk/lib/index.browser.tests.js +++ b/packages/optimizely-sdk/lib/index.browser.tests.js @@ -15,8 +15,10 @@ */ import { assert } from 'chai'; import sinon from 'sinon'; -import * as logging from '@optimizely/js-sdk-logging'; -import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +// import * as logging from '@optimizely/js-sdk-logging'; +var logging = require('./logging/logger'); +// import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +var eventProcessor = require('../event-processor/eventProcessor'); import Optimizely from './optimizely'; import testData from './tests/test_data'; diff --git a/packages/optimizely-sdk/lib/index.d.ts b/packages/optimizely-sdk/lib/index.d.ts index af31fabaa..cc3b400e3 100644 --- a/packages/optimizely-sdk/lib/index.d.ts +++ b/packages/optimizely-sdk/lib/index.d.ts @@ -291,3 +291,4 @@ declare module '@optimizely/optimizely-sdk/lib/plugins/event_dispatcher' {} declare module '@optimizely/optimizely-sdk/lib/utils/json_schema_validator' {} declare module '@optimizely/optimizely-sdk/lib/plugins/error_handler' {} + diff --git a/packages/optimizely-sdk/lib/index.node.js b/packages/optimizely-sdk/lib/index.node.js index a3a983d2d..32de2faca 100644 --- a/packages/optimizely-sdk/lib/index.node.js +++ b/packages/optimizely-sdk/lib/index.node.js @@ -13,14 +13,20 @@ * See the License for the specific language governing permissions and * * limitations under the License. * ***************************************************************************/ -import { - getLogger, - setLogHandler, - setLogLevel, - setErrorHandler, - getErrorHandler, - LogLevel, -} from '@optimizely/js-sdk-logging'; +// import { +// getLogger, +// setLogHandler, +// setLogLevel, +// setErrorHandler, +// getErrorHandler, +// LogLevel, +// } from '@optimizely/js-sdk-logging'; +var getLogger = require('./logging/logger') +var setLogHandler = require('./logging/logger') +var setLogLevel = require('./logging/logger') +var setErrorHandler = require('./logging/errorHandler') +var getErrorHandler = require('./logging/errorHandler') +var LogLevel = require('./logging/models') import fns from './utils/fns'; import Optimizely from './optimizely'; diff --git a/packages/optimizely-sdk/lib/index.node.tests.js b/packages/optimizely-sdk/lib/index.node.tests.js index 09422c29e..aeb45032f 100644 --- a/packages/optimizely-sdk/lib/index.node.tests.js +++ b/packages/optimizely-sdk/lib/index.node.tests.js @@ -15,7 +15,8 @@ */ import { assert } from 'chai'; import sinon from 'sinon'; -import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +// import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +var eventProcessor = require('../event-processor/eventProcessor'); import enums from './utils/enums'; import Optimizely from './optimizely'; diff --git a/packages/optimizely-sdk/lib/index.react_native.js b/packages/optimizely-sdk/lib/index.react_native.js index a7b17a4f9..015c0d26a 100644 --- a/packages/optimizely-sdk/lib/index.react_native.js +++ b/packages/optimizely-sdk/lib/index.react_native.js @@ -13,14 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - getLogger, - setLogHandler, - setLogLevel, - setErrorHandler, - getErrorHandler, - LogLevel, -} from '@optimizely/js-sdk-logging'; +// import { +// getLogger, +// setLogHandler, +// setLogLevel, +// setErrorHandler, +// getErrorHandler, +// LogLevel, +// } from '@optimizely/js-sdk-logging'; +var getLogger = require('./logging/logger') +var setLogHandler = require('./logging/logger') +var setLogLevel = require('./logging/logger') +var setErrorHandler = require('./logging/errorHandler') +var getErrorHandler = require('./logging/errorHandler') +var LogLevel = require('./logging/models') +var logging = require('../logging/logger'); import fns from './utils/fns'; import enums from './utils/enums'; diff --git a/packages/optimizely-sdk/lib/index.react_native.tests.js b/packages/optimizely-sdk/lib/index.react_native.tests.js index 3ec4d9672..77de80b9e 100644 --- a/packages/optimizely-sdk/lib/index.react_native.tests.js +++ b/packages/optimizely-sdk/lib/index.react_native.tests.js @@ -15,8 +15,10 @@ */ import { assert } from 'chai'; import sinon from 'sinon'; -import * as logging from '@optimizely/js-sdk-logging'; -import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +// import * as logging from '@optimizely/js-sdk-logging'; +var logging = require('../logging/logger'); +// import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +var eventProcessor = require('../event-processor/eventProcessor'); import Optimizely from './optimizely'; import testData from './tests/test_data'; diff --git a/packages/optimizely-sdk/lib/logging/errorHandler.ts b/packages/optimizely-sdk/lib/logging/errorHandler.ts new file mode 100644 index 000000000..bb659aeae --- /dev/null +++ b/packages/optimizely-sdk/lib/logging/errorHandler.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2019, 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 + * + * http://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 + * @interface ErrorHandler + */ +export interface ErrorHandler { + /** + * @param {Error} exception + * @memberof ErrorHandler + */ + handleError(exception: Error): void +} + +/** + * @export + * @class NoopErrorHandler + * @implements {ErrorHandler} + */ +export class NoopErrorHandler implements ErrorHandler { + /** + * @param {Error} exception + * @memberof NoopErrorHandler + */ + handleError(exception: Error): void { + // no-op + return + } +} + +let globalErrorHandler: ErrorHandler = new NoopErrorHandler() + +/** + * @export + * @param {ErrorHandler} handler + */ +export function setErrorHandler(handler: ErrorHandler): void { + globalErrorHandler = handler +} + +/** + * @export + * @returns {ErrorHandler} + */ +export function getErrorHandler(): ErrorHandler { + return globalErrorHandler +} + +/** + * @export + */ +export function resetErrorHandler(): void { + globalErrorHandler = new NoopErrorHandler() +} diff --git a/packages/optimizely-sdk/lib/logging/index.ts b/packages/optimizely-sdk/lib/logging/index.ts new file mode 100644 index 000000000..47a1e99c8 --- /dev/null +++ b/packages/optimizely-sdk/lib/logging/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2019, 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 + * + * http://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 * from './errorHandler' +export * from './models' +export * from './logger' diff --git a/packages/optimizely-sdk/lib/logging/logger.ts b/packages/optimizely-sdk/lib/logging/logger.ts new file mode 100644 index 000000000..815e5208d --- /dev/null +++ b/packages/optimizely-sdk/lib/logging/logger.ts @@ -0,0 +1,323 @@ +/** + * Copyright 2019, 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 + * + * http://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 { getErrorHandler } from './errorHandler' +// import { isValidEnum, sprintf } from '@optimizely/js-sdk-utils' +var isValidEnum = require('../pkg-utils/index'); +var sprintf = require('../pkg-utils/index'); + + +import { LogLevel, LoggerFacade, LogManager, LogHandler } from './models' + +const stringToLogLevel = { + NOTSET: 0, + DEBUG: 1, + INFO: 2, + WARNING: 3, + ERROR: 4, +} + +function coerceLogLevel(level: any): LogLevel { + if (typeof level !== 'string') { + return level + } + + level = level.toUpperCase() + if (level === 'WARN') { + level = 'WARNING' + } + + if (!stringToLogLevel[level]) { + return level + } + + return stringToLogLevel[level] +} + +type LogData = { + message: string + splat: any[] + error?: Error +} + +class DefaultLogManager implements LogManager { + private loggers: { + [name: string]: LoggerFacade + } + private defaultLoggerFacade = new OptimizelyLogger() + + constructor() { + this.loggers = {} + } + + getLogger(name?: string): LoggerFacade { + if (!name) { + return this.defaultLoggerFacade + } + + if (!this.loggers[name]) { + this.loggers[name] = new OptimizelyLogger({ messagePrefix: name }) + } + + return this.loggers[name] + } +} + +type ConsoleLogHandlerConfig = { + logLevel?: LogLevel | string + logToConsole?: boolean + prefix?: string +} + +export class ConsoleLogHandler implements LogHandler { + public logLevel: LogLevel + private logToConsole: boolean + private prefix: string + + /** + * Creates an instance of ConsoleLogger. + * @param {ConsoleLogHandlerConfig} config + * @memberof ConsoleLogger + */ + constructor(config: ConsoleLogHandlerConfig = {}) { + this.logLevel = LogLevel.NOTSET + if (config.logLevel !== undefined && isValidEnum(LogLevel, config.logLevel)) { + this.setLogLevel(config.logLevel) + } + + this.logToConsole = config.logToConsole !== undefined ? !!config.logToConsole : true + this.prefix = config.prefix !== undefined ? config.prefix : '[OPTIMIZELY]' + } + + /** + * @param {LogLevel} level + * @param {string} message + * @memberof ConsoleLogger + */ + log(level: LogLevel, message: string) { + if (!this.shouldLog(level) || !this.logToConsole) { + return + } + + let logMessage: string = `${this.prefix} - ${this.getLogLevelName( + level, + )} ${this.getTime()} ${message}` + + this.consoleLog(level, [logMessage]) + } + + /** + * @param {LogLevel} level + * @memberof ConsoleLogger + */ + setLogLevel(level: LogLevel | string) { + level = coerceLogLevel(level) + if (!isValidEnum(LogLevel, level) || level === undefined) { + this.logLevel = LogLevel.ERROR + } else { + this.logLevel = level + } + } + + /** + * @returns {string} + * @memberof ConsoleLogger + */ + getTime(): string { + return new Date().toISOString() + } + + /** + * @private + * @param {LogLevel} targetLogLevel + * @returns {boolean} + * @memberof ConsoleLogger + */ + private shouldLog(targetLogLevel: LogLevel): boolean { + return targetLogLevel >= this.logLevel + } + + /** + * @private + * @param {LogLevel} logLevel + * @returns {string} + * @memberof ConsoleLogger + */ + private getLogLevelName(logLevel: LogLevel): string { + switch (logLevel) { + case LogLevel.DEBUG: + return 'DEBUG' + case LogLevel.INFO: + return 'INFO ' + case LogLevel.WARNING: + return 'WARN ' + case LogLevel.ERROR: + return 'ERROR' + default: + return 'NOTSET' + } + } + + /** + * @private + * @param {LogLevel} logLevel + * @param {string[]} logArguments + * @memberof ConsoleLogger + */ + private consoleLog(logLevel: LogLevel, logArguments: [string, ...string[]]) { + switch (logLevel) { + case LogLevel.DEBUG: + console.log.apply(console, logArguments) + break + case LogLevel.INFO: + console.info.apply(console, logArguments) + break + case LogLevel.WARNING: + console.warn.apply(console, logArguments) + break + case LogLevel.ERROR: + console.error.apply(console, logArguments) + break + default: + console.log.apply(console, logArguments) + } + } +} + +let globalLogLevel: LogLevel = LogLevel.NOTSET +let globalLogHandler: LogHandler | null = null + +class OptimizelyLogger implements LoggerFacade { + private messagePrefix: string = '' + + constructor(opts: { messagePrefix?: string } = {}) { + if (opts.messagePrefix) { + this.messagePrefix = opts.messagePrefix + } + } + + /** + * @param {(LogLevel | LogInputObject)} levelOrObj + * @param {string} [message] + * @memberof OptimizelyLogger + */ + log(level: LogLevel | string, message: string): void { + this.internalLog(coerceLogLevel(level), { + message, + splat: [], + }) + } + + info(message: string | Error, ...splat: any[]): void { + this.namedLog(LogLevel.INFO, message, splat) + } + + debug(message: string | Error, ...splat: any[]): void { + this.namedLog(LogLevel.DEBUG, message, splat) + } + + warn(message: string | Error, ...splat: any[]): void { + this.namedLog(LogLevel.WARNING, message, splat) + } + + error(message: string | Error, ...splat: any[]): void { + this.namedLog(LogLevel.ERROR, message, splat) + } + + private format(data: LogData): string { + return `${this.messagePrefix ? this.messagePrefix + ': ' : ''}${sprintf( + data.message, + ...data.splat, + )}` + } + + private internalLog(level: LogLevel, data: LogData): void { + if (!globalLogHandler) { + return + } + + if (level < globalLogLevel) { + return + } + + globalLogHandler.log(level, this.format(data)) + + if (data.error && data.error instanceof Error) { + getErrorHandler().handleError(data.error) + } + } + + private namedLog(level: LogLevel, message: string | Error, splat: any[]): void { + let error: Error | undefined + + if (message instanceof Error) { + error = message + message = error.message + this.internalLog(level, { + error, + message, + splat, + }) + return + } + + if (splat.length === 0) { + this.internalLog(level, { + message, + splat, + }) + return + } + + const last = splat[splat.length - 1] + if (last instanceof Error) { + error = last + splat.splice(-1) + } + + this.internalLog(level, { message, error, splat }) + } +} + +let globalLogManager: LogManager = new DefaultLogManager() + +export function getLogger(name?: string): LoggerFacade { + return globalLogManager.getLogger(name) +} + +export function setLogHandler(logger: LogHandler | null) { + globalLogHandler = logger +} + +export function setLogLevel(level: LogLevel | string) { + level = coerceLogLevel(level) + if (!isValidEnum(LogLevel, level) || level === undefined) { + globalLogLevel = LogLevel.ERROR + } else { + globalLogLevel = level + } +} + +export function getLogLevel(): LogLevel { + return globalLogLevel +} + +/** + * Resets all global logger state to it's original + */ +export function resetLogger() { + globalLogManager = new DefaultLogManager() + globalLogLevel = LogLevel.NOTSET +} diff --git a/packages/optimizely-sdk/lib/logging/models.ts b/packages/optimizely-sdk/lib/logging/models.ts new file mode 100644 index 000000000..d8d628e08 --- /dev/null +++ b/packages/optimizely-sdk/lib/logging/models.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2019, 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 + * + * http://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 enum LogLevel { + NOTSET = 0, + DEBUG = 1, + INFO = 2, + WARNING = 3, + ERROR = 4, +} + +export interface LoggerFacade { + log(level: LogLevel | string, message: string): void + + info(message: string | Error, ...splat: any[]): void + + debug(message: string | Error, ...splat: any[]): void + + warn(message: string | Error, ...splat: any[]): void + + error(message: string | Error, ...splat: any[]): void +} + +export interface LogManager { + getLogger(name?: string): LoggerFacade +} + +export interface LogHandler { + log(level: LogLevel, message: string): void +} \ No newline at end of file diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index f051fd3e1..b0e45bbe0 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * * limitations under the License. * ***************************************************************************/ -import { sprintf, objectValues } from '@optimizely/js-sdk-utils'; -import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +// import { sprintf, objectValues } from '@optimizely/js-sdk-utils'; +var objectValues = require('../pkg-utils/index'); +var sprintf = require('../pkg-utils/index'); +// import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +var eventProcessor = require('../event-processor/eventProcessor'); import fns from '../utils/fns' import { validate } from '../utils/attributes_validator'; diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 9153fc2a2..3024bce65 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -15,9 +15,13 @@ ***************************************************************************/ import { assert } from 'chai'; import sinon from 'sinon'; -import { sprintf } from '@optimizely/js-sdk-utils'; -import * as eventProcessor from '@optimizely/js-sdk-event-processor'; -import * as logging from '@optimizely/js-sdk-logging'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../pkg-utils/index'); +// import * as eventProcessor from '@optimizely/js-sdk-event-processor'; +var eventProcessor = require('../event-processor/eventProcessor'); + +// import * as logging from '@optimizely/js-sdk-logging'; +var logging = require('../logging/logger'); import Optimizely from './'; import AudienceEvaluator from '../core/audience_evaluator'; diff --git a/packages/optimizely-sdk/lib/pkg-utils/index.js b/packages/optimizely-sdk/lib/pkg-utils/index.js new file mode 100644 index 000000000..74a4bd101 --- /dev/null +++ b/packages/optimizely-sdk/lib/pkg-utils/index.js @@ -0,0 +1,146 @@ +/** + * Copyright 2019, 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 + * + * http://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 { v4 } from 'uuid'; +export function generateUUID() { + return v4(); +} +export function getTimestamp() { + return new Date().getTime(); +} +/** + * Validates a value is a valid TypeScript enum + * + * @export + * @param {object} enumToCheck + * @param {*} value + * @returns {boolean} + */ +export function isValidEnum(enumToCheck, value) { + var found = false; + var keys = Object.keys(enumToCheck); + for (var index = 0; index < keys.length; index++) { + if (value === enumToCheck[keys[index]]) { + found = true; + break; + } + } + return found; +} +export function groupBy(arr, grouperFn) { + var grouper = {}; + arr.forEach(function (item) { + var key = grouperFn(item); + grouper[key] = grouper[key] || []; + grouper[key].push(item); + }); + return objectValues(grouper); +} +export function objectValues(obj) { + return Object.keys(obj).map(function (key) { return obj[key]; }); +} +export function objectEntries(obj) { + return Object.keys(obj).map(function (key) { return [key, obj[key]]; }); +} +export function find(arr, cond) { + var found; + for (var _i = 0, arr_1 = arr; _i < arr_1.length; _i++) { + var item = arr_1[_i]; + if (cond(item)) { + found = item; + break; + } + } + return found; +} +export function keyBy(arr, keyByFn) { + var map = {}; + arr.forEach(function (item) { + var key = keyByFn(item); + map[key] = item; + }); + return map; +} +export function sprintf(format) { + var args = []; + for (var _i = 1; _i < arguments.length; _i++) { + args[_i - 1] = arguments[_i]; + } + var i = 0; + return format.replace(/%s/g, function () { + var arg = args[i++]; + var type = typeof arg; + if (type === 'function') { + return arg(); + } + else if (type === 'string') { + return arg; + } + else { + return String(arg); + } + }); +} +/* + * Notification types for use with NotificationCenter + * Format is EVENT: + * + * SDK consumers can use these to register callbacks with the notification center. + * + * @deprecated since 3.1.0 + * ACTIVATE: An impression event will be sent to Optimizely + * Callbacks will receive an object argument with the following properties: + * - experiment {Object} + * - userId {string} + * - attributes {Object|undefined} + * - variation {Object} + * - logEvent {Object} + * + * DECISION: A decision is made in the system. i.e. user activation, + * feature access or feature-variable value retrieval + * Callbacks will receive an object argument with the following properties: + * - type {string} + * - userId {string} + * - attributes {Object|undefined} + * - decisionInfo {Object|undefined} + * + * LOG_EVENT: A batch of events, which could contain impressions and/or conversions, + * will be sent to Optimizely + * Callbacks will receive an object argument with the following properties: + * - url {string} + * - httpVerb {string} + * - params {Object} + * + * OPTIMIZELY_CONFIG_UPDATE: This Optimizely instance has been updated with a new + * config + * + * TRACK: A conversion event will be sent to Optimizely + * Callbacks will receive the an object argument with the following properties: + * - eventKey {string} + * - userId {string} + * - attributes {Object|undefined} + * - eventTags {Object|undefined} + * - logEvent {Object} + * + */ +export var NOTIFICATION_TYPES; +(function (NOTIFICATION_TYPES) { + NOTIFICATION_TYPES["ACTIVATE"] = "ACTIVATE:experiment, user_id,attributes, variation, event"; + NOTIFICATION_TYPES["DECISION"] = "DECISION:type, userId, attributes, decisionInfo"; + NOTIFICATION_TYPES["LOG_EVENT"] = "LOG_EVENT:logEvent"; + NOTIFICATION_TYPES["OPTIMIZELY_CONFIG_UPDATE"] = "OPTIMIZELY_CONFIG_UPDATE"; + NOTIFICATION_TYPES["TRACK"] = "TRACK:event_key, user_id, attributes, event_tags, event"; +})(NOTIFICATION_TYPES || (NOTIFICATION_TYPES = {})); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/optimizely-sdk/lib/plugins/logger/enums.js b/packages/optimizely-sdk/lib/plugins/logger/enums.js index 1ec490c5b..3597f3854 100644 --- a/packages/optimizely-sdk/lib/plugins/logger/enums.js +++ b/packages/optimizely-sdk/lib/plugins/logger/enums.js @@ -13,4 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { LOG_LEVEL as LogLevel } from '@optimizely/js-sdk-logging'; +export { LOG_LEVEL as LogLevel} from '../../logging/models'; + +export { LogHandler } from '../../logging/models'; + +export { ErrorHandler } from '../../logging/errorHandler'; + diff --git a/packages/optimizely-sdk/lib/plugins/logger/index.js b/packages/optimizely-sdk/lib/plugins/logger/index.js index f9a3ff36b..4300200a3 100644 --- a/packages/optimizely-sdk/lib/plugins/logger/index.js +++ b/packages/optimizely-sdk/lib/plugins/logger/index.js @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ConsoleLogHandler } from '@optimizely/js-sdk-logging'; +// import { ConsoleLogHandler } from '@optimizely/js-sdk-logging'; +var ConsoleLogHandler = require('../../logging/models'); + function NoOpLogger() {} diff --git a/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js b/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js index 22cc2841e..2092eb62c 100644 --- a/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js +++ b/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js @@ -13,8 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LogLevel } from '@optimizely/js-sdk-logging'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { LogLevel } from '@optimizely/js-sdk-logging'; +var LogLevel = require('../../logging/models'); + +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + function getLogLevelName(level) { switch (level) { diff --git a/packages/optimizely-sdk/lib/utils/attributes_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/attributes_validator/index.tests.js index 91dfaef55..c50926204 100644 --- a/packages/optimizely-sdk/lib/utils/attributes_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/attributes_validator/index.tests.js @@ -14,7 +14,8 @@ * limitations under the License. */ import { assert } from 'chai'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import * as attributesValidator from './'; import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/lib/utils/attributes_validator/index.ts b/packages/optimizely-sdk/lib/utils/attributes_validator/index.ts index 8fdccbe50..e31424c63 100644 --- a/packages/optimizely-sdk/lib/utils/attributes_validator/index.ts +++ b/packages/optimizely-sdk/lib/utils/attributes_validator/index.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import fns from '../../utils/fns'; import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/lib/utils/config_validator/index.js b/packages/optimizely-sdk/lib/utils/config_validator/index.js index dfdb51dea..257f01ea8 100644 --- a/packages/optimizely-sdk/lib/utils/config_validator/index.js +++ b/packages/optimizely-sdk/lib/utils/config_validator/index.js @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import { ERROR_MESSAGES, diff --git a/packages/optimizely-sdk/lib/utils/config_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/config_validator/index.tests.js index 65c4c24d9..36f8507e4 100644 --- a/packages/optimizely-sdk/lib/utils/config_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/config_validator/index.tests.js @@ -14,7 +14,9 @@ * limitations under the License. */ import { assert } from 'chai'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + import configValidator from './'; import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/lib/utils/enums/index.js b/packages/optimizely-sdk/lib/utils/enums/index.js index b3e4f9eb1..927fa8c77 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.js +++ b/packages/optimizely-sdk/lib/utils/enums/index.js @@ -14,7 +14,8 @@ * limitations under the License. * ***************************************************************************/ -import { NOTIFICATION_TYPES as notificationTypesEnum } from '@optimizely/js-sdk-utils'; +// import { NOTIFICATION_TYPES as notificationTypesEnum } from '@optimizely/js-sdk-utils'; +var notificationTypesEnum = require('../../pkg-utils/index'); /** * Contains global enums used throughout the library diff --git a/packages/optimizely-sdk/lib/utils/event_tag_utils/index.ts b/packages/optimizely-sdk/lib/utils/event_tag_utils/index.ts index 56e8528c8..bb9ba3c3d 100644 --- a/packages/optimizely-sdk/lib/utils/event_tag_utils/index.ts +++ b/packages/optimizely-sdk/lib/utils/event_tag_utils/index.ts @@ -13,10 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + +// import { EventTags } from '@optimizely/js-sdk-event-processor'; +var EventTags = require('../../event-processor/events.ts'); + +// import { LoggerFacade } from '@optimizely/js-sdk-logging'; +var LoggerFacade = require('../../logging/models'); -import { EventTags } from '@optimizely/js-sdk-event-processor'; -import { LoggerFacade } from '@optimizely/js-sdk-logging'; import { LOG_LEVEL, @@ -37,7 +42,7 @@ const VALUE_EVENT_METRIC_NAME = RESERVED_EVENT_KEYWORDS.VALUE; * @param {LoggerFacade} logger * @return {number|null} */ -export function getRevenueValue(eventTags: EventTags, logger: LoggerFacade): number | null { +export function getRevenueValue(eventTags: typeof EventTags, logger: typeof LoggerFacade): number | null { if (eventTags.hasOwnProperty(REVENUE_EVENT_METRIC_NAME)) { const rawValue = eventTags[REVENUE_EVENT_METRIC_NAME]; let parsedRevenueValue; @@ -66,7 +71,7 @@ export function getRevenueValue(eventTags: EventTags, logger: LoggerFacade): num * @param {LoggerFacade} logger * @return {number|null} */ -export function getEventValue(eventTags: EventTags, logger: LoggerFacade): number | null { +export function getEventValue(eventTags: typeof EventTags, logger: typeof LoggerFacade): number | null { if (eventTags.hasOwnProperty(VALUE_EVENT_METRIC_NAME)) { const rawValue = eventTags[VALUE_EVENT_METRIC_NAME]; let parsedEventValue; diff --git a/packages/optimizely-sdk/lib/utils/event_tags_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/event_tags_validator/index.tests.js index 4dde65d18..d6417975c 100644 --- a/packages/optimizely-sdk/lib/utils/event_tags_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/event_tags_validator/index.tests.js @@ -14,7 +14,9 @@ * limitations under the License. */ import { assert } from 'chai'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + import { validate } from './'; import { ERROR_MESSAGES } from'../enums'; diff --git a/packages/optimizely-sdk/lib/utils/event_tags_validator/index.ts b/packages/optimizely-sdk/lib/utils/event_tags_validator/index.ts index 6e97c0fd6..49d6ab39d 100644 --- a/packages/optimizely-sdk/lib/utils/event_tags_validator/index.ts +++ b/packages/optimizely-sdk/lib/utils/event_tags_validator/index.ts @@ -17,7 +17,9 @@ /** * Provides utility method for validating that event tags user has provided are valid */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/lib/utils/fns/index.js b/packages/optimizely-sdk/lib/utils/fns/index.js index 8dc395d41..8cb6823fb 100644 --- a/packages/optimizely-sdk/lib/utils/fns/index.js +++ b/packages/optimizely-sdk/lib/utils/fns/index.js @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { generateUUID as uuid, keyBy as keyByUtil } from '@optimizely/js-sdk-utils'; +// import { generateUUID as uuid, keyBy as keyByUtil } from '@optimizely/js-sdk-utils'; +var uuid = require('../../pkg-utils/index'); +var keyByUtil = require('../../pkg-utils/index'); var MAX_SAFE_INTEGER_LIMIT = Math.pow(2, 53); diff --git a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js index 58b550a7e..1248fc6ca 100644 --- a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import { assert } from 'chai'; import { validate } from './'; diff --git a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts index 7fa16c05e..739ff8a51 100644 --- a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts +++ b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.ts @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); + import { validate as jsonSchemaValidator } from 'json-schema'; import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.tests.js index 33aaa077c..bbd163cb4 100644 --- a/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.tests.js @@ -15,7 +15,8 @@ ***************************************************************************/ import { assert } from 'chai'; -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import { validate } from './'; import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.ts b/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.ts index 9c264178e..3782337ed 100644 --- a/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.ts +++ b/packages/optimizely-sdk/lib/utils/user_profile_service_validator/index.ts @@ -18,7 +18,8 @@ * Provides utility method for validating that the given user profile service implementation is valid. */ -import { sprintf } from '@optimizely/js-sdk-utils'; +// import { sprintf } from '@optimizely/js-sdk-utils'; +var sprintf = require('../../pkg-utils/index'); import { ERROR_MESSAGES } from '../enums'; diff --git a/packages/optimizely-sdk/package-lock.json b/packages/optimizely-sdk/package-lock.json index f98d30950..fa843af06 100644 --- a/packages/optimizely-sdk/package-lock.json +++ b/packages/optimizely-sdk/package-lock.json @@ -380,25 +380,6 @@ "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", "dev": true }, - "@optimizely/js-sdk-datafile-manager": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.7.0.tgz", - "integrity": "sha512-pphm9o9ats3TCPgKiSfZm35Fk/tF0Tz/RXSqcEJZd1u6Bm1kYNze0ZBHCr3NTH927vo0gglNZZxB/UEELpdYBg==", - "requires": { - "@optimizely/js-sdk-logging": "^0.1.0", - "@optimizely/js-sdk-utils": "^0.4.0", - "decompress-response": "^4.2.1" - } - }, - "@optimizely/js-sdk-event-processor": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.6.0.tgz", - "integrity": "sha512-wNwuyUb563MDxVCHTlDCAGu6lVqHfv3K3ig4QZiR2HPpDo0bT0+zRFuqe4gbor6yfcOe3LDsq4xIxW2TxY2x4g==", - "requires": { - "@optimizely/js-sdk-logging": "^0.1.0", - "@optimizely/js-sdk-utils": "^0.4.0" - } - }, "@optimizely/js-sdk-logging": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.1.0.tgz", @@ -417,14 +398,21 @@ } } }, - "@optimizely/js-sdk-utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-utils/-/js-sdk-utils-0.4.0.tgz", - "integrity": "sha512-QG2oytnITW+VKTJK+l0RxjaS5VrA6W+AZMzpeg4LCB4Rn4BEKtF+EcW/5S1fBDLAviGq/0TLpkjM3DlFkJ9/Gw==", + "@react-native-community/async-storage": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@react-native-community/async-storage/-/async-storage-1.11.0.tgz", + "integrity": "sha512-Pq9LlmvtCEKAGdkyrgTcRxNh2fnHFykEj2qnRYijOl1pDIl2MkD5IxaXu5eOL0wgOtAl4U//ff4z40Td6XR5rw==", + "dev": true, "requires": { - "uuid": "^3.3.2" + "deep-assign": "^3.0.0" } }, + "@react-native-community/netinfo": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-5.9.5.tgz", + "integrity": "sha512-PbSsRmhRwYIMdeVJTf9gJtvW0TVq/hmgz1xyjsrTIsQ7QS7wbMEiv1Eb/M/y6AEEsdUped5Axm5xykq9TGISHg==", + "dev": true + }, "@rollup/plugin-commonjs": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-11.0.2.tgz", @@ -533,10 +521,9 @@ "dev": true }, "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==", - "dev": true + "version": "12.7.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.4.tgz", + "integrity": "sha512-W0+n1Y+gK/8G2P/piTkBBN38Qc5Q1ZSO6B5H3QmPCUewaiXOo2GCAWZ4ElZCcNhjJuBSUSLGFUJnmlCn5+nxOQ==" }, "@types/resolve": { "version": "0.0.8", @@ -547,6 +534,11 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.1.tgz", + "integrity": "sha512-2kE8rEFgJpbBAPw5JghccEevQb0XVU0tewF/8h7wPQTeCtoJ6h8qmBIwuzUVm2MutmzC/cpCkwxudixoNYDp1A==" + }, "@typescript-eslint/eslint-plugin": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.2.0.tgz", @@ -2239,10 +2231,20 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "dev": true, "requires": { "mimic-response": "^2.0.0" } }, + "deep-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/deep-assign/-/deep-assign-3.0.0.tgz", + "integrity": "sha512-YX2i9XjJ7h5q/aQ/IM9PEwEnDqETAIYbggmdDB3HLTlSgo1CxPsj6pvhPG68rq6SVE0+p+6Ywsm5fTYNrYtBWw==", + "dev": true, + "requires": { + "is-obj": "^1.0.0" + } + }, "deep-eql": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", @@ -4447,6 +4449,12 @@ } } }, + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true + }, "is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -5343,7 +5351,8 @@ "mimic-response": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", - "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==" + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "dev": true }, "minimalistic-assert": { "version": "1.0.1", diff --git a/packages/optimizely-sdk/package.json b/packages/optimizely-sdk/package.json index ba154513e..e91f50d85 100644 --- a/packages/optimizely-sdk/package.json +++ b/packages/optimizely-sdk/package.json @@ -16,7 +16,8 @@ "test-xbrowser": "karma start karma.bs.conf.js --single-run", "test-umdbrowser": "npm run build-browser-umd && karma start karma.umd.conf.js --single-run", "prebuild": "npm run clean", - "build": "rollup -c", + "build": "rollup -c && tsc ", + "tsc": "tsc", "build-browser-umd": "rollup -c --config-umd", "precover": "nyc npm test", "cover": "nyc report -r lcov", @@ -41,10 +42,9 @@ }, "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/optimizely-sdk", "dependencies": { - "@optimizely/js-sdk-datafile-manager": "^0.7.0", - "@optimizely/js-sdk-event-processor": "^0.6.0", "@optimizely/js-sdk-logging": "^0.1.0", - "@optimizely/js-sdk-utils": "^0.4.0", + "@types/node": "^12.7.4", + "@types/uuid": "^8.0.1", "json-schema": "^0.2.3", "murmurhash": "0.0.2" }, @@ -79,7 +79,11 @@ "ts-loader": "^7.0.5", "ts-node": "^8.10.2", "typescript": "^3.3.3333", - "webpack": "^4.42.1" + "webpack": "^4.42.1", + "decompress-response": "^4.2.1", + "@react-native-community/async-storage": "^1.2.0", + "@react-native-community/netinfo": "^5.9.4", + "uuid": "^3.3.2" }, "publishConfig": { "access": "public" diff --git a/packages/optimizely-sdk/tsconfig.json b/packages/optimizely-sdk/tsconfig.json index 250ef5bea..67934bd94 100644 --- a/packages/optimizely-sdk/tsconfig.json +++ b/packages/optimizely-sdk/tsconfig.json @@ -5,7 +5,8 @@ "declaration": false, "module": "esnext", "outDir": "./dist", - "sourceMap": true + "sourceMap": true, + "noImplicitAny": false }, "exclude": [ "./dist",