diff --git a/.eslintrc b/.eslintrc index dabb965..9954be9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -30,7 +30,8 @@ "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "error", "keyword-spacing": "error", - "comma-style": "error" + "comma-style": "error", + "no-trailing-spaces": "error" }, "overrides": [ diff --git a/CHANGES.txt b/CHANGES.txt index 213a27b..d35c78b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,6 @@ -0.8.1 (September XXX, 2022) -- Updated @splitsoftware/splitio-commons package to version 1.6.2, that improves the performance of split evaluations (i.e., `getTreatment(s)` method calls) when using the default storage in memory. +0.9.0 (October 5, 2022) +- Added a new impressions mode for the SDK called NONE, to be used in factory when there is no desire to capture impressions on an SDK factory to feed Split's analytics engine. Running NONE mode, the SDK will only capture unique keys evaluated for a particular feature flag instead of full blown impressions. +- Updated @splitsoftware/splitio-commons package to version 1.7.0, that improves the performance of split evaluations (i.e., `getTreatment(s)` method calls) when using the default storage in memory, among other improvements. 0.8.0 (July 22, 2022) - Added `autoRequire` configuration option to the Google Analytics to Split integration, which takes care of requiring the splitTracker plugin on trackers dynamically created by Google tag managers (See https://help.split.io/hc/en-us/articles/360040838752#set-up-with-gtm-and-gtag.js). diff --git a/package-lock.json b/package-lock.json index 742795d..3838356 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-browserjs", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2194,9 +2194,9 @@ "dev": true }, "@splitsoftware/splitio-commons": { - "version": "1.6.2-rc.9", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.6.2-rc.9.tgz", - "integrity": "sha512-pUTAuhZlkTBgs5nGu5in4jDdlvj0yPRdnqXWUjNml4+GmRuUx827R8FL07jFHV2m88K9BbqZfpztsgbis5hRHQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.7.0.tgz", + "integrity": "sha512-QjDcSV2mbwkhMkA9zutmdU4NwY/Hb09EZeXFCmRxWHHYLegl6XWztbghqD6fw3Q5yb7jsQ+wn7nMEZsvNKum7g==", "requires": { "tslib": "^2.3.1" } diff --git a/package.json b/package.json index a35a08c..e85665d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-browserjs", - "version": "0.8.0", + "version": "0.9.0", "description": "Split SDK for Javascript on Browser", "main": "cjs/index.js", "module": "esm/index.js", @@ -42,6 +42,7 @@ "pretest-ts-decls": "npm run build:esm && npm run build:cjs && npm link", "test-ts-decls": "./scripts/ts-tests.sh", "posttest-ts-decls": "npm unlink && npm install", + "all": "npm run check && npm run build && npm run test-ts-decls && npm run test", "publish:rc": "npm run check && npm run build && npm publish --tag rc", "publish:stable": "npm run check && npm run build && npm publish" }, @@ -63,7 +64,7 @@ "bugs": "https://github.com/splitio/javascript-browser-client/issues", "homepage": "https://github.com/splitio/javascript-browser-client#readme", "dependencies": { - "@splitsoftware/splitio-commons": "1.6.2-rc.9", + "@splitsoftware/splitio-commons": "1.7.0", "@types/google.analytics": "0.0.40" }, "devDependencies": { diff --git a/src/__tests__/browserSuites/impressions.spec.js b/src/__tests__/browserSuites/impressions.spec.js index 8e73c5d..6be91e6 100644 --- a/src/__tests__/browserSuites/impressions.spec.js +++ b/src/__tests__/browserSuites/impressions.spec.js @@ -94,7 +94,7 @@ export default function (fetchMock, assert) { fetchMock.postOnce(url(settings, '/testImpressions/count'), (url, opts) => { const data = JSON.parse(opts.body); - assert.equal(data.pf.length, 2, 'We should generated 2 impressions count.'); + assert.equal(data.pf.length, 1, 'We should generate impressions count for one feature.'); // finding these validate the feature names collection too const dependencyChildImpr = data.pf.filter(e => e.f === 'hierarchical_splits_test')[0]; diff --git a/src/__tests__/browserSuites/telemetry.spec.js b/src/__tests__/browserSuites/telemetry.spec.js index e6ebbad..80f1ac1 100644 --- a/src/__tests__/browserSuites/telemetry.spec.js +++ b/src/__tests__/browserSuites/telemetry.spec.js @@ -69,8 +69,9 @@ export default async function telemetryBrowserSuite(fetchMock, assert) { assert.equal(getLatencyCount(data.mL.tr), 1, 'One latency metric for track'); delete data.hL; delete data.mL; + // @TODO check if iDe value is correct assert.deepEqual(data, { - mE: {}, hE: { sp: { 500: 1 }, ms: { 500: 1 } }, tR: 0, aR: 0, iQ: 4, iDe: 3, iDr: 0, spC: 31, seC: 1, skC: 1, eQ: 1, eD: 0, sE: [], t: [] + mE: {}, hE: { sp: { 500: 1 }, ms: { 500: 1 } }, tR: 0, aR: 0, iQ: 4, iDe: 1, iDr: 0, spC: 31, seC: 1, skC: 1, eQ: 1, eD: 0, sE: [], t: [] }, 'metrics/usage JSON payload should be the expected'); finish.next(); @@ -87,9 +88,10 @@ export default async function telemetryBrowserSuite(fetchMock, assert) { assert.true(data.sL > 0, 'sessionLengthMs must be defined'); delete data.sL; + // @TODO check if iDe value is correct assert.deepEqual(data, { mL: {}, mE: {}, hE: {}, hL: {}, // errors and latencies were popped - tR: 0, aR: 0, iQ: 4, iDe: 3, iDr: 0, spC: 31, seC: 1, skC: 1, eQ: 1, eD: 0, sE: [], t: [] + tR: 0, aR: 0, iQ: 4, iDe: 1, iDr: 0, spC: 31, seC: 1, skC: 1, eQ: 1, eD: 0, sE: [], t: [] }, '2nd metrics/usage JSON payload should be the expected'); return 200; }); diff --git a/src/__tests__/browserSuites/use-beacon-api.spec.js b/src/__tests__/browserSuites/use-beacon-api.spec.js index 8cb2122..41ab094 100644 --- a/src/__tests__/browserSuites/use-beacon-api.spec.js +++ b/src/__tests__/browserSuites/use-beacon-api.spec.js @@ -110,7 +110,8 @@ function beaconApiSendTest(fetchMock, assert) { const splitio = SplitFactory(config); const client = splitio.client(); client.on(client.Event.SDK_READY, () => { - client.getTreatment('hierarchical_splits_test'); + client.getTreatment('hierarchical_splits_test'); // first impression counted in backend + client.getTreatment('hierarchical_splits_test'); // impression counted in sdk client.track('sometraffictype', 'someEvent', 10); // trigger both events inmmediatly, before scheduled push of events and impressions, to assert that beacon requests are not duplicated @@ -174,7 +175,8 @@ function fallbackTest(fetchMock, assert) { }); client.on(client.Event.SDK_READY, () => { - client.getTreatment('hierarchical_splits_test'); + client.getTreatment('hierarchical_splits_test');// first impression counted in backend + client.getTreatment('hierarchical_splits_test');// impression counted in sdk client.track('sometraffictype', 'someEvent', 10); // trigger both events inmmediatly, before scheduled push of events and impressions, to assert that POST requests are not duplicated triggerPagehideEvent(); diff --git a/src/__tests__/browserSuites/user-consent.spec.js b/src/__tests__/browserSuites/user-consent.spec.js index 7dd3cad..024fcb4 100644 --- a/src/__tests__/browserSuites/user-consent.spec.js +++ b/src/__tests__/browserSuites/user-consent.spec.js @@ -158,7 +158,8 @@ export default function userConsent(fetchMock, t) { await client.ready(); assert.equal(client.track('user', 'event1'), true, 'Events queue is full, but submitter is not executed'); - assert.equal(client.getTreatment('always_on'), 'on', 'Impressions queue is full, but submitter is not executed'); + assert.equal(client.getTreatment('always_on'), 'on', 'Impressions queue is full, but submitter is not executed');// First impression counted in backend + assert.equal(client.getTreatment('always_on'), 'on', 'Impressions queue is full, but submitter is not executed');// impression counted in sdk let submitterCalls = 0; const start = Date.now(); diff --git a/src/__tests__/consumer/browser_consumer.spec.js b/src/__tests__/consumer/browser_consumer.spec.js index b3f206b..1144853 100644 --- a/src/__tests__/consumer/browser_consumer.spec.js +++ b/src/__tests__/consumer/browser_consumer.spec.js @@ -2,6 +2,7 @@ import tape from 'tape-catch'; import sinon from 'sinon'; import { inMemoryWrapperFactory } from '@splitsoftware/splitio-commons/src/storages/pluggable/inMemoryWrapper'; import { SDK_NOT_READY, EXCEPTION } from '@splitsoftware/splitio-commons/src/utils/labels'; +import { truncateTimeFrame } from '@splitsoftware/splitio-commons/src/utils/time'; import { applyOperations } from './wrapper-commands'; import { nearlyEqual } from '../testUtils'; import { version } from '../../../package.json'; @@ -15,6 +16,8 @@ const wrapperPrefix = 'PLUGGABLE_STORAGE_UT'; const wrapperInstance = inMemoryWrapperFactory(); const TOTAL_RAW_IMPRESSIONS = 18; const TOTAL_EVENTS = 5; +const DEDUPED_IMPRESSIONS = 2; +const timeFrame = Date.now(); /** @type SplitIO.IBrowserAsyncSettings */ const config = { @@ -26,12 +29,15 @@ const config = { storage: PluggableStorage({ prefix: wrapperPrefix, wrapper: wrapperInstance - }) + }), + sync: { + impressionsMode: 'DEBUG' + }, }; tape('Browser Consumer mode with pluggable storage', function (t) { - t.test('Regular usage', async (assert) => { + t.test('Regular usage - DEBUG strategy', async (assert) => { // Load wrapper with data to do the proper tests await applyOperations(wrapperInstance); @@ -174,6 +180,292 @@ tape('Browser Consumer mode with pluggable storage', function (t) { }); }); + t.test('Regular usage - OPTIMIZED strategy', async (assert) => { + const wrapperInstance = inMemoryWrapperFactory(); + // Load wrapper with data to do the proper tests + await applyOperations(wrapperInstance); + + /** @type SplitIO.ImpressionData[] */ + const impressions = []; + const disconnectSpy = sinon.spy(wrapperInstance, 'disconnect'); + + // Overwrite Math.random to track telemetry + const originalMathRandom = Math.random; Math.random = () => 0.5; + const sdk = SplitFactory({ + ...config, + storage: PluggableStorage({ + prefix: wrapperPrefix, + wrapper: wrapperInstance + }), + sync: { + impressionsMode: 'OPTIMIZED' + }, + impressionListener: { + logImpression(data) { impressions.push(data); } + } + }); + Math.random = originalMathRandom; // restore + + const client = sdk.client(); + const otherClient = sdk.client('emi@split.io'); + + /** Evaluation and track methods before SDK_READY */ + + const getTreatmentResult = client.getTreatment('UT_IN_SEGMENT'); + + assert.equal(typeof getTreatmentResult.then, 'function', 'GetTreatment calls should always return a promise on Consumer mode.'); + assert.equal(await getTreatmentResult, 'control', 'Evaluations using pluggable storage should be control if initiated before SDK_READY.'); + assert.equal(client.__getStatus().isReadyFromCache, false, 'SDK in consumer mode is not operational inmediatelly'); + assert.equal(client.__getStatus().isReady, false, 'SDK in consumer mode is not operational inmediatelly'); + + const trackResult = otherClient.track('user', 'test.event', 18); + assert.equal(typeof trackResult.then, 'function', 'Track calls should always return a promise on Consumer mode.'); + assert.true(await trackResult, 'If the wrapper operation success to queue the event, the promise will resolve to true'); + + /** Evaluation and track methods on SDK_READY with OPTIMIZED impressions mode */ + + await client.ready(); + await otherClient.ready(); // waiting to avoid 'control' with label 'sdk not ready' + + assert.equal(await client.getTreatment('UT_IN_SEGMENT'), 'on', '`getTreatment` evaluation using pluggable storage should be correct.'); + assert.equal((await otherClient.getTreatmentWithConfig('UT_IN_SEGMENT')).treatment, 'off', '`getTreatmentWithConfig` evaluation using pluggable storage should be correct.'); + + assert.equal((await client.getTreatments(['UT_NOT_IN_SEGMENT']))['UT_NOT_IN_SEGMENT'], 'off', '`getTreatments` evaluation using pluggable storage should be correct.'); + assert.equal((await otherClient.getTreatmentsWithConfig(['UT_NOT_IN_SEGMENT']))['UT_NOT_IN_SEGMENT'].treatment, 'on', '`getTreatmentsWithConfig` evaluation using pluggable storage should be correct.'); + + client.setAttribute('permissions', ['not_matching']); + assert.equal(await client.getTreatment('UT_SET_MATCHER', { + permissions: ['admin'] + }), 'on', 'Evaluations using pluggable storage should be correct.'); + client.setAttributes({ permissions: ['admin'] }); + assert.equal(await client.getTreatment('UT_SET_MATCHER', { + permissions: ['not_matching'] + }), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('UT_SET_MATCHER'), 'on', 'Evaluations using pluggable storage and attributes binding should be correct.'); + + client.setAttribute('permissions', ['not_matching']); + assert.equal(await client.getTreatment('UT_NOT_SET_MATCHER', { + permissions: ['create'] + }), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('UT_NOT_SET_MATCHER'), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.deepEqual(await client.getTreatmentWithConfig('UT_NOT_SET_MATCHER'), { + treatment: 'on', + config: null + }, 'Evaluations using pluggable storage should be correct, including configs.'); + client.clearAttributes(); + assert.deepEqual(await client.getTreatmentWithConfig('always-o.n-with-config'), { + treatment: 'o.n', + config: '{"color":"brown"}' + }, 'Evaluations using pluggable storage should be correct, including configs.'); + + assert.equal(await client.getTreatment('always-on'), 'on', 'Evaluations using pluggable storage should be correct.'); + + // Below splits were added manually. + // They are all_keys (always evaluate to on) which depend from always-on split. the _on/off is what treatment they are expecting there. + assert.equal(await client.getTreatment('hierarchical_splits_testing_on'), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('hierarchical_splits_testing_off'), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('hierarchical_splits_testing_on_negated'), 'off', 'Evaluations using pluggable storage should be correct.'); + + assert.equal(typeof client.track('user', 'test.event', 18).then, 'function', 'Track calls should always return a promise on Consumer mode.'); + assert.equal(typeof client.track().then, 'function', 'Track calls should always return a promise on Consumer mode, even when parameters are incorrect.'); + + assert.true(await client.track('user', 'test.event', 18), 'If the event was succesfully queued the promise will resolve to true'); + assert.false(await client.track(), 'If the event was NOT succesfully queued the promise will resolve to false'); + + // New shared client created + const newClient = sdk.client('other'); + newClient.track('user', 'test.event', 18).then(result => assert.true(result, 'Track should attempt to track, whether SDK_READY has been emitted or not.')); + newClient.getTreatment('UT_IN_SEGMENT').then(result => assert.equal(result, 'control', '`getTreatment` evaluation is control if shared client is not ready yet.')); + + await newClient.ready(); + assert.true(await newClient.track('user', 'test.event', 18), 'Track should attempt to track, whether SDK_READY has been emitted or not.'); + assert.equal((await newClient.getTreatment('UT_IN_SEGMENT')), 'off', '`getTreatment` evaluation using pluggable storage should be correct.'); + + await client.ready(); // promise already resolved + await newClient.destroy(); + await otherClient.destroy(); + await client.destroy(); + + assert.equal(disconnectSpy.callCount, 1, 'Wrapper disconnect method should be called only once, when the main client is destroyed'); + + // Validate stored impressions and events + const trackedImpressions = await wrapperInstance.popItems('PLUGGABLE_STORAGE_UT.SPLITIO.impressions', await wrapperInstance.getItemsCount('PLUGGABLE_STORAGE_UT.SPLITIO.impressions')); + const trackedEvents = await wrapperInstance.popItems('PLUGGABLE_STORAGE_UT.SPLITIO.events', await wrapperInstance.getItemsCount('PLUGGABLE_STORAGE_UT.SPLITIO.events')); + assert.equal(trackedImpressions.length, TOTAL_RAW_IMPRESSIONS - DEDUPED_IMPRESSIONS, 'Tracked impressions should be present in the external storage'); + assert.equal(trackedEvents.length, TOTAL_EVENTS, 'Tracked events should be present in the external storage'); + + // Validate impression counts + const trackedImpressionCounts = await wrapperInstance.getKeysByPrefix('PLUGGABLE_STORAGE_UT.SPLITIO.impressions.count') + .then(impressionCountsKeys => Promise.all(impressionCountsKeys.map(key => wrapperInstance.get(key).then(count => ([key, count]))))); + const expectedImpressionCount = [ + [`${wrapperPrefix}.SPLITIO.impressions.count::UT_SET_MATCHER::${truncateTimeFrame(timeFrame)}`, '1'], + [`${wrapperPrefix}.SPLITIO.impressions.count::UT_NOT_SET_MATCHER::${truncateTimeFrame(timeFrame)}`, '1'], + ]; + assert.deepEqual(trackedImpressionCounts, expectedImpressionCount, 'Impression counts should be present in the external storage'); + + // Assert impressionsListener + setTimeout(() => { + assert.equal(impressions.length, TOTAL_RAW_IMPRESSIONS, 'Each evaluation has its corresponting impression'); + assert.equal(impressions[0].impression.label, SDK_NOT_READY, 'The first impression is control with label "sdk not ready"'); + + assert.end(); + }); + }); + + t.test('Regular usage - NONE strategy', async (assert) => { + const wrapperInstance = inMemoryWrapperFactory(); + // Load wrapper with data to do the proper tests + await applyOperations(wrapperInstance); + + /** @type SplitIO.ImpressionData[] */ + const impressions = []; + const disconnectSpy = sinon.spy(wrapperInstance, 'disconnect'); + + // Overwrite Math.random to not track telemetry + const originalMathRandom = Math.random; Math.random = () => 0.5; + const sdk = SplitFactory({ + ...config, + storage: PluggableStorage({ + prefix: wrapperPrefix, + wrapper: wrapperInstance + }), + sync: { + impressionsMode: 'NONE' + }, + impressionListener: { + logImpression(data) { impressions.push(data); } + } + }); + Math.random = originalMathRandom; // restore + + const client = sdk.client(); + const otherClient = sdk.client('emi@split.io'); + + /** Evaluation and track methods before SDK_READY */ + + const getTreatmentResult = client.getTreatment('UT_IN_SEGMENT'); + + assert.equal(typeof getTreatmentResult.then, 'function', 'GetTreatment calls should always return a promise on Consumer mode.'); + assert.equal(await getTreatmentResult, 'control', 'Evaluations using pluggable storage should be control if initiated before SDK_READY.'); + assert.equal(client.__getStatus().isReadyFromCache, false, 'SDK in consumer mode is not operational inmediatelly'); + assert.equal(client.__getStatus().isReady, false, 'SDK in consumer mode is not operational inmediatelly'); + + const trackResult = otherClient.track('user', 'test.event', 18); + assert.equal(typeof trackResult.then, 'function', 'Track calls should always return a promise on Consumer mode.'); + assert.true(await trackResult, 'If the wrapper operation success to queue the event, the promise will resolve to true'); + + /** Evaluation and track methods on SDK_READY with OPTIMIZED impressions mode */ + + await client.ready(); + await otherClient.ready(); // waiting to avoid 'control' with label 'sdk not ready' + + assert.equal(await client.getTreatment('UT_IN_SEGMENT'), 'on', '`getTreatment` evaluation using pluggable storage should be correct.'); + assert.equal((await otherClient.getTreatmentWithConfig('UT_IN_SEGMENT')).treatment, 'off', '`getTreatmentWithConfig` evaluation using pluggable storage should be correct.'); + + assert.equal((await client.getTreatments(['UT_NOT_IN_SEGMENT']))['UT_NOT_IN_SEGMENT'], 'off', '`getTreatments` evaluation using pluggable storage should be correct.'); + assert.equal((await otherClient.getTreatmentsWithConfig(['UT_NOT_IN_SEGMENT']))['UT_NOT_IN_SEGMENT'].treatment, 'on', '`getTreatmentsWithConfig` evaluation using pluggable storage should be correct.'); + + client.setAttribute('permissions', ['not_matching']); + assert.equal(await client.getTreatment('UT_SET_MATCHER', { + permissions: ['admin'] + }), 'on', 'Evaluations using pluggable storage should be correct.'); + client.setAttributes({ permissions: ['admin'] }); + assert.equal(await client.getTreatment('UT_SET_MATCHER', { + permissions: ['not_matching'] + }), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('UT_SET_MATCHER'), 'on', 'Evaluations using pluggable storage and attributes binding should be correct.'); + + client.setAttribute('permissions', ['not_matching']); + assert.equal(await client.getTreatment('UT_NOT_SET_MATCHER', { + permissions: ['create'] + }), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('UT_NOT_SET_MATCHER'), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.deepEqual(await client.getTreatmentWithConfig('UT_NOT_SET_MATCHER'), { + treatment: 'on', + config: null + }, 'Evaluations using pluggable storage should be correct, including configs.'); + client.clearAttributes(); + assert.deepEqual(await client.getTreatmentWithConfig('always-o.n-with-config'), { + treatment: 'o.n', + config: '{"color":"brown"}' + }, 'Evaluations using pluggable storage should be correct, including configs.'); + + assert.equal(await client.getTreatment('always-on'), 'on', 'Evaluations using pluggable storage should be correct.'); + + // Below splits were added manually. + // They are all_keys (always evaluate to on) which depend from always-on split. the _on/off is what treatment they are expecting there. + assert.equal(await client.getTreatment('hierarchical_splits_testing_on'), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('hierarchical_splits_testing_off'), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('hierarchical_splits_testing_on_negated'), 'off', 'Evaluations using pluggable storage should be correct.'); + + assert.equal(typeof client.track('user', 'test.event', 18).then, 'function', 'Track calls should always return a promise on Consumer mode.'); + assert.equal(typeof client.track().then, 'function', 'Track calls should always return a promise on Consumer mode, even when parameters are incorrect.'); + + assert.true(await client.track('user', 'test.event', 18), 'If the event was succesfully queued the promise will resolve to true'); + assert.false(await client.track(), 'If the event was NOT succesfully queued the promise will resolve to false'); + + // New shared client created + const newClient = sdk.client('other'); + newClient.track('user', 'test.event', 18).then(result => assert.true(result, 'Track should attempt to track, whether SDK_READY has been emitted or not.')); + newClient.getTreatment('UT_IN_SEGMENT').then(result => assert.equal(result, 'control', '`getTreatment` evaluation is control if shared client is not ready yet.')); + + await newClient.ready(); + assert.true(await newClient.track('user', 'test.event', 18), 'Track should attempt to track, whether SDK_READY has been emitted or not.'); + assert.equal((await newClient.getTreatment('UT_IN_SEGMENT')), 'off', '`getTreatment` evaluation using pluggable storage should be correct.'); + + await client.ready(); // promise already resolved + await newClient.destroy(); + await otherClient.destroy(); + await client.destroy(); + + assert.equal(disconnectSpy.callCount, 1, 'Wrapper disconnect method should be called only once, when the main client is destroyed'); + + // Validate stored events and no impressions + const trackedImpressions = await wrapperInstance.popItems('PLUGGABLE_STORAGE_UT.SPLITIO.impressions', await wrapperInstance.getItemsCount('PLUGGABLE_STORAGE_UT.SPLITIO.impressions')); + const trackedEvents = await wrapperInstance.popItems('PLUGGABLE_STORAGE_UT.SPLITIO.events', await wrapperInstance.getItemsCount('PLUGGABLE_STORAGE_UT.SPLITIO.events')); + assert.equal(trackedImpressions.length, 0, 'No impressions are tracked in NONE impressions mode'); + assert.equal(trackedEvents.length, TOTAL_EVENTS, 'Tracked events should be present in the external storage'); + + // Validate impression counts + const trackedImpressionCounts = await wrapperInstance.getKeysByPrefix('PLUGGABLE_STORAGE_UT.SPLITIO.impressions.count') + .then(impressionCountsKeys => Promise.all(impressionCountsKeys.map(key => wrapperInstance.get(key).then(count => ([key, count]))))); + const expectedImpressionCount = [ + [`${wrapperPrefix}.SPLITIO.impressions.count::UT_IN_SEGMENT::${truncateTimeFrame(timeFrame)}`, '5'], + [`${wrapperPrefix}.SPLITIO.impressions.count::UT_NOT_IN_SEGMENT::${truncateTimeFrame(timeFrame)}`, '2'], + [`${wrapperPrefix}.SPLITIO.impressions.count::UT_SET_MATCHER::${truncateTimeFrame(timeFrame)}`, '3'], + [`${wrapperPrefix}.SPLITIO.impressions.count::UT_NOT_SET_MATCHER::${truncateTimeFrame(timeFrame)}`, '3'], + [`${wrapperPrefix}.SPLITIO.impressions.count::always-o.n-with-config::${truncateTimeFrame(timeFrame)}`, '1'], + [`${wrapperPrefix}.SPLITIO.impressions.count::always-on::${truncateTimeFrame(timeFrame)}`, '1'], + [`${wrapperPrefix}.SPLITIO.impressions.count::hierarchical_splits_testing_on::${truncateTimeFrame(timeFrame)}`, '1'], + [`${wrapperPrefix}.SPLITIO.impressions.count::hierarchical_splits_testing_off::${truncateTimeFrame(timeFrame)}`, '1'], + [`${wrapperPrefix}.SPLITIO.impressions.count::hierarchical_splits_testing_on_negated::${truncateTimeFrame(timeFrame)}`, '1'] + ]; + assert.deepEqual(trackedImpressionCounts, expectedImpressionCount, 'Impression counts should be present in the external storage'); + + // Validate unique keys + const trackedUniqueKeys = await wrapperInstance.popItems('PLUGGABLE_STORAGE_UT.SPLITIO.uniquekeys', await wrapperInstance.getItemsCount('PLUGGABLE_STORAGE_UT.SPLITIO.uniquekeys')); + const expectedUniqueKeys = [ + JSON.stringify({ 'f': 'UT_IN_SEGMENT', 'ks': ['UT_Segment_member', 'emi@split.io', 'other'] }), + JSON.stringify({ 'f': 'UT_NOT_IN_SEGMENT', 'ks': ['UT_Segment_member', 'emi@split.io'] }), + JSON.stringify({ 'f': 'UT_SET_MATCHER', 'ks': ['UT_Segment_member'] }), + JSON.stringify({ 'f': 'UT_NOT_SET_MATCHER', 'ks': ['UT_Segment_member'] }), + JSON.stringify({ 'f': 'always-o.n-with-config', 'ks': ['UT_Segment_member'] }), + JSON.stringify({ 'f': 'always-on', 'ks': ['UT_Segment_member'] }), + JSON.stringify({ 'f': 'hierarchical_splits_testing_on', 'ks': ['UT_Segment_member'] }), + JSON.stringify({ 'f': 'hierarchical_splits_testing_off', 'ks': ['UT_Segment_member'] }), + JSON.stringify({ 'f': 'hierarchical_splits_testing_on_negated', 'ks': ['UT_Segment_member'] }), + ]; + assert.deepEqual(trackedUniqueKeys, expectedUniqueKeys); + + // Assert impressionsListener + setTimeout(() => { + assert.equal(impressions.length, TOTAL_RAW_IMPRESSIONS, 'Each evaluation has its corresponting impression'); + assert.equal(impressions[0].impression.label, SDK_NOT_READY, 'The first impression is control with label "sdk not ready"'); + + assert.end(); + }); + }); + t.test('Connection timeout and then ready', assert => { const connDelay = 110; const readyTimeout = 0.1; // 100 millis diff --git a/src/__tests__/consumer/browser_consumer_partial.spec.js b/src/__tests__/consumer/browser_consumer_partial.spec.js index 4f493c7..5c705ab 100644 --- a/src/__tests__/consumer/browser_consumer_partial.spec.js +++ b/src/__tests__/consumer/browser_consumer_partial.spec.js @@ -44,7 +44,7 @@ tape('Browser Consumer Partial mode with pluggable storage', function (t) { * We only validates regular usage. * Corner cases, such as wrapper connection timeout and operation errors, are validated in `browser_consumer.spec.js` */ - t.test('Regular usage', async (assert) => { + t.test('Regular usage - OPTIMIZED strategy', async (assert) => { fetchMock.postOnce(url(config, '/testImpressions/bulk'), (url, req) => { assert.equal(req.headers.SplitSDKImpressionsMode, OPTIMIZED, 'Impressions mode is OPTIMIZED by default'); @@ -194,4 +194,151 @@ tape('Browser Consumer Partial mode with pluggable storage', function (t) { }); }); + t.test('Regular usage - NONE strategy', async (assert) => { + + fetchMock.postOnce(url(config, '/testImpressions/count'), 200); + fetchMock.postOnce(url(config, '/v1/keys/cs'), (url, req) => { + const data = JSON.parse(req.body); + + assert.deepEqual(data, { + keys: [ + { + k: 'UT_Segment_member', + fs: ['UT_IN_SEGMENT', 'UT_NOT_IN_SEGMENT', 'UT_SET_MATCHER', 'UT_NOT_SET_MATCHER', 'always-o.n-with-config', 'always-on', 'hierarchical_splits_testing_on', 'hierarchical_splits_testing_off', 'hierarchical_splits_testing_on_negated'] + }, + { + k: 'emi@split.io', + fs: ['UT_IN_SEGMENT', 'UT_NOT_IN_SEGMENT'] + }, + { + k: 'other', + fs: ['UT_IN_SEGMENT'] + }] + }, 'We performed evaluations for 3 keys, so we should have 3 items total.'); + + return 200; + }); + + fetchMock.postOnce(url(config, '/events/bulk'), (url, req) => { + const resp = JSON.parse(req.body); + assert.equal(resp.length, TOTAL_EVENTS, 'All successfully tracked events were sent'); + return 200; + }); + + // Load wrapper with data to do the proper tests + const wrapperInstance = inMemoryWrapperFactory(); + await applyOperations(wrapperInstance); + + /** @type SplitIO.ImpressionData[] */ + const impressions = []; + const disconnectSpy = sinon.spy(wrapperInstance, 'disconnect'); + + // Overwrite Math.random to NOT track telemetry + const originalMathRandom = Math.random; Math.random = () => 0.5; + const sdk = SplitFactory({ + ...config, + storage: PluggableStorage({ + prefix: wrapperPrefix, + wrapper: wrapperInstance + }), + sync: { + impressionsMode: 'NONE' + }, + impressionListener: { + logImpression(data) { impressions.push(data); } + } + }); + Math.random = originalMathRandom; // restore + + const client = sdk.client(); + const otherClient = sdk.client('emi@split.io'); + + /** Evaluation, track and manager methods before SDK_READY */ + + const getTreatmentResult = client.getTreatment('UT_IN_SEGMENT'); + + assert.equal(typeof getTreatmentResult.then, 'function', 'GetTreatment calls should always return a promise on Consumer mode.'); + assert.equal(await getTreatmentResult, 'control', 'Evaluations using pluggable storage should be control if initiated before SDK_READY.'); + assert.equal(client.__getStatus().isReadyFromCache, false, 'SDK in consumer mode is not operational inmediatelly'); + assert.equal(client.__getStatus().isReady, false, 'SDK in consumer mode is not operational inmediatelly'); + + const trackResult = otherClient.track('user', 'test.event', 18); + assert.equal(typeof trackResult.then, 'function', 'Track calls should always return a promise on Consumer mode.'); + assert.true(await trackResult, 'If the wrapper operation success to queue the event, the promise will resolve to true'); + + /** Evaluation, track and manager methods on SDK_READY */ + + await client.ready(); + await otherClient.ready(); // waiting to avoid 'control' with label 'sdk not ready' + + assert.equal(await client.getTreatment('UT_IN_SEGMENT'), 'on', '`getTreatment` evaluation using pluggable storage should be correct.'); + assert.equal((await otherClient.getTreatmentWithConfig('UT_IN_SEGMENT')).treatment, 'off', '`getTreatmentWithConfig` evaluation using pluggable storage should be correct.'); + + assert.equal((await client.getTreatments(['UT_NOT_IN_SEGMENT']))['UT_NOT_IN_SEGMENT'], 'off', '`getTreatments` evaluation using pluggable storage should be correct.'); + assert.equal((await otherClient.getTreatmentsWithConfig(['UT_NOT_IN_SEGMENT']))['UT_NOT_IN_SEGMENT'].treatment, 'on', '`getTreatmentsWithConfig` evaluation using pluggable storage should be correct.'); + + assert.equal(await client.getTreatment('UT_SET_MATCHER', { + permissions: ['admin'] + }), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('UT_SET_MATCHER', { + permissions: ['not_matching'] + }), 'off', 'Evaluations using pluggable storage should be correct.'); + + assert.equal(await client.getTreatment('UT_NOT_SET_MATCHER', { + permissions: ['create'] + }), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('UT_NOT_SET_MATCHER', { + permissions: ['not_matching'] + }), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.deepEqual(await client.getTreatmentWithConfig('UT_NOT_SET_MATCHER', { + permissions: ['not_matching'] + }), { + treatment: 'on', + config: null + }, 'Evaluations using pluggable storage should be correct, including configs.'); + assert.deepEqual(await client.getTreatmentWithConfig('always-o.n-with-config'), { + treatment: 'o.n', + config: '{"color":"brown"}' + }, 'Evaluations using pluggable storage should be correct, including configs.'); + + assert.equal(await client.getTreatment('always-on'), 'on', 'Evaluations using pluggable storage should be correct.'); + + // Below splits were added manually. + // They are all_keys (always evaluate to on) which depend from always-on split. the _on/off is what treatment they are expecting there. + assert.equal(await client.getTreatment('hierarchical_splits_testing_on'), 'on', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('hierarchical_splits_testing_off'), 'off', 'Evaluations using pluggable storage should be correct.'); + assert.equal(await client.getTreatment('hierarchical_splits_testing_on_negated'), 'off', 'Evaluations using pluggable storage should be correct.'); + + assert.equal(typeof client.track('user', 'test.event', 18).then, 'function', 'Track calls should always return a promise on Consumer mode.'); + assert.equal(typeof client.track().then, 'function', 'Track calls should always return a promise on Consumer mode, even when parameters are incorrect.'); + + assert.true(await client.track('user', 'test.event', 18), 'If the event was succesfully queued the promise will resolve to true'); + assert.false(await client.track(), 'If the event was NOT succesfully queued the promise will resolve to false'); + + // New shared client created + const newClient = sdk.client('other'); + newClient.track('user', 'test.event', 18).then(result => assert.true(result, 'Track should attempt to track, whether SDK_READY has been emitted or not.')); + newClient.getTreatment('UT_IN_SEGMENT').then(result => assert.equal(result, 'control', '`getTreatment` evaluation is control if shared client is not ready yet.')); + + await newClient.ready(); + assert.true(await newClient.track('user', 'test.event', 18), 'Track should attempt to track, whether SDK_READY has been emitted or not.'); + assert.equal((await newClient.getTreatment('UT_IN_SEGMENT')), 'off', '`getTreatment` evaluation using pluggable storage should be correct.'); + + await client.ready(); // promise already resolved + await newClient.destroy(); + await otherClient.destroy(); + + await client.destroy(); + + assert.equal(disconnectSpy.callCount, 1, 'Wrapper disconnect method should be called only once, when the main client is destroyed'); + + // Assert impressionsListener + setTimeout(() => { + assert.equal(impressions.length, TOTAL_RAW_IMPRESSIONS, 'Each evaluation has its corresponting impression'); + assert.equal(impressions[0].impression.label, SDK_NOT_READY, 'The first impression is control with label "sdk not ready"'); + + assert.end(); + }); + }); + }); diff --git a/src/__tests__/testUtils/index.js b/src/__tests__/testUtils/index.js index 302e944..2717982 100644 --- a/src/__tests__/testUtils/index.js +++ b/src/__tests__/testUtils/index.js @@ -43,7 +43,7 @@ export function hasNoCacheHeader(fetchMockOpts) { return fetchMockOpts.headers['Cache-Control'] === 'no-cache'; } -const telemetryEndpointMatcher = /^\/v1\/metrics\/(config|usage)/; +const telemetryEndpointMatcher = /^\/v1\/(metrics|keys)\/(config|usage|ss|cs)/; const eventsEndpointMatcher = /^\/(testImpressions|metrics|events)/; const authEndpointMatcher = /^\/v2\/auth/; const streamingEndpointMatcher = /^\/(sse|event-stream)/; diff --git a/src/platform/getModules.ts b/src/platform/getModules.ts index 8d5416f..90c45e9 100644 --- a/src/platform/getModules.ts +++ b/src/platform/getModules.ts @@ -8,7 +8,6 @@ import { BrowserSignalListener } from '@splitsoftware/splitio-commons/src/listen import { impressionObserverCSFactory } from '@splitsoftware/splitio-commons/src/trackers/impressionObserver/impressionObserverCS'; import { pluggableIntegrationsManagerFactory } from '@splitsoftware/splitio-commons/src/integrations/pluggable'; -import { shouldAddPt } from '@splitsoftware/splitio-commons/src/trackers/impressionObserver/utils'; import { IPlatform, ISdkFactoryParams } from '@splitsoftware/splitio-commons/src/sdkFactory/types'; import { ISettings } from '@splitsoftware/splitio-commons/src/types'; import { CONSUMER_MODE, CONSUMER_PARTIAL_MODE, LOCALHOST_MODE } from '@splitsoftware/splitio-commons/src/utils/constants'; @@ -40,7 +39,7 @@ export function getModules(settings: ISettings, platform: IPlatform): ISdkFactor integrationsManagerFactory: settings.integrations && settings.integrations.length > 0 ? pluggableIntegrationsManagerFactory.bind(null, settings.integrations) : undefined, - impressionsObserverFactory: shouldAddPt(settings) ? impressionObserverCSFactory : undefined, + impressionsObserverFactory: impressionObserverCSFactory, extraProps: (params) => { return { diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 5a9b687..60aaad6 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -2,7 +2,7 @@ import { LogLevels, isLogLevelString } from '@splitsoftware/splitio-commons/src/ import { ConsentStatus, LogLevel } from '@splitsoftware/splitio-commons/src/types'; import { CONSENT_GRANTED } from '@splitsoftware/splitio-commons/src/utils/constants'; -const packageVersion = '0.8.0'; +const packageVersion = '0.9.0'; /** * In browser, the default debug level, can be set via the `localStorage.splitio_debug` item. diff --git a/types/splitio.d.ts b/types/splitio.d.ts index d54f534..a1e9d14 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -217,9 +217,11 @@ interface ISharedSettings { splitFilters?: SplitIO.SplitFilter[] /** * Impressions Collection Mode. Option to determine how impressions are going to be sent to Split Servers. - * Possible values are 'DEBUG' and 'OPTIMIZED'. + * Possible values are 'DEBUG', 'OPTIMIZED', and 'NONE'. * - DEBUG: will send all the impressions generated (recommended only for debugging purposes). - * - OPTIMIZED: will send unique impressions to Split Servers avoiding a considerable amount of traffic that duplicated impressions could generate. + * - OPTIMIZED: will send unique impressions to Split Servers, avoiding a considerable amount of traffic that duplicated impressions could generate. + * - NONE: will send unique keys evaluated per feature to Split Servers instead of full blown impressions, avoiding a considerable amount of traffic that impressions could generate. + * * @property {String} impressionsMode * @default 'OPTIMIZED' */ @@ -809,7 +811,7 @@ declare namespace SplitIO { * ImpressionsMode type * @typedef {string} ImpressionsMode */ - type ImpressionsMode = 'OPTIMIZED' | 'DEBUG'; + type ImpressionsMode = 'OPTIMIZED' | 'DEBUG' | 'NONE'; /** * User consent status. * @typedef {string} ConsentStatus