diff --git a/.vscode/settings.json b/.vscode/settings.json index 7869db3b0..f0cf98fd7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { - "jest.rootPath": "/workspaces/javascript-sdk/packages/optimizely-sdk", + "jest.rootPath": "/workspaces/javascript-sdk", "jest.jestCommandLine": "./node_modules/.bin/jest", - "jest.autoRevealOutput": "on-exec-error", - "editor.tabSize": 2 -} \ No newline at end of file + "jest.outputConfig": "test-results-based", + "editor.tabSize": 2, + "jest.runMode": "deferred" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fd73fdf..d5cec7747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [6.0.0] - November 5, 2024 + +### Breaking Changes + +- VUID is now opt-in instead of being automatically generated ([#950](https://github.com/optimizely/javascript-sdk/pull/950), [#951](https://github.com/optimizely/javascript-sdk/pull/951), [#954](https://github.com/optimizely/javascript-sdk/pull/954)) +- require node >= 16 + +### New Features + +- User Profile Service calls are batched in decideAll and decideForKeys methods ([#953](https://github.com/optimizely/javascript-sdk/pull/953)) + +### Bug Fixes + +- When "activate" is called with a rollout experiment key (not ab-test), "invalid experiment key" is returned ([#953](https://github.com/optimizely/javascript-sdk/pull/953)) + + ## [5.3.4] - Jun 28, 2024 ### Changed diff --git a/lib/core/decision_service/index.tests.js b/lib/core/decision_service/index.tests.js index f61d67630..9787a62bd 100644 --- a/lib/core/decision_service/index.tests.js +++ b/lib/core/decision_service/index.tests.js @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022 Optimizely, Inc. and contributors * + * Copyright 2017-2022,2024 Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -379,7 +379,7 @@ describe('lib/core/decision_service', function() { ); assert.strictEqual( buildLogMessageFromArgs(mockLogger.log.args[4]), - 'DECISION_SERVICE: Saved variation "control" of experiment "testExperiment" for user "decision_service_user".' + 'DECISION_SERVICE: Saved user profile for user "decision_service_user".' ); }); @@ -392,6 +392,7 @@ describe('lib/core/decision_service', function() { optimizely: {}, userId: 'decision_service_user', }); + assert.strictEqual( 'control', decisionServiceInstance.getVariation(configObj, experiment, user).result @@ -400,11 +401,11 @@ describe('lib/core/decision_service', function() { sinon.assert.calledOnce(bucketerStub); // should still go through with bucketing assert.strictEqual( buildLogMessageFromArgs(mockLogger.log.args[0]), - 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.' + 'DECISION_SERVICE: Error while looking up user profile for user ID "decision_service_user": I am an error.' ); assert.strictEqual( buildLogMessageFromArgs(mockLogger.log.args[1]), - 'DECISION_SERVICE: Error while looking up user profile for user ID "decision_service_user": I am an error.' + 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.' ); }); @@ -1277,7 +1278,7 @@ describe('lib/core/decision_service', function() { reasons: [], }; experiment = configObj.experimentIdMap['594098']; - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponse); getVariationStub.withArgs(configObj, experiment, user).returns(fakeDecisionResponseWithArgs); }); @@ -1493,12 +1494,11 @@ describe('lib/core/decision_service', function() { decisionSource: DECISION_SOURCES.FEATURE_TEST, }; assert.deepEqual(decision, expectedDecision); - sinon.assert.calledWithExactly( + sinon.assert.calledWith( getVariationStub, configObj, experiment, user, - {} ); }); }); @@ -1511,7 +1511,7 @@ describe('lib/core/decision_service', function() { optimizely: {}, userId: 'user1', }); - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponse); }); @@ -1550,7 +1550,7 @@ describe('lib/core/decision_service', function() { result: 'var', reasons: [], }; - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponseWithArgs); getVariationStub.withArgs(configObj, 'exp_with_group', user).returns(fakeDecisionResponseWithArgs); }); @@ -1607,7 +1607,7 @@ describe('lib/core/decision_service', function() { optimizely: {}, userId: 'user1', }); - getVariationStub = sandbox.stub(decisionServiceInstance, 'getVariation'); + getVariationStub = sandbox.stub(decisionServiceInstance, 'resolveVariation'); getVariationStub.returns(fakeDecisionResponse); }); diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index 28f97a09e..1d1581273 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2022 Optimizely, Inc. and contributors * + * Copyright 2017-2022,2024 Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -55,7 +55,7 @@ import { Variation, } from '../../shared_types'; -const MODULE_NAME = 'DECISION_SERVICE'; +export const MODULE_NAME = 'DECISION_SERVICE'; export interface DecisionObj { experiment: Experiment | null; @@ -73,6 +73,11 @@ interface DeliveryRuleResponse extends DecisionResponse { skipToEveryoneElse: K; } +interface UserProfileTracker { + userProfile: ExperimentBucketMap | null; + isProfileUpdated: boolean; +} + /** * Optimizely's decision service that determines which variation of an experiment the user will be allocated to. * @@ -102,20 +107,21 @@ export class DecisionService { } /** - * Gets variation where visitor will be bucketed. - * @param {ProjectConfig} configObj The parsed project configuration object - * @param {Experiment} experiment - * @param {OptimizelyUserContext} user A user context - * @param {[key: string]: boolean} options Optional map of decide options - * @return {DecisionResponse} DecisionResponse containing the variation the user is bucketed into - * and the decide reasons. + * Resolves the variation into which the visitor will be bucketed. + * + * @param {ProjectConfig} configObj - The parsed project configuration object. + * @param {Experiment} experiment - The experiment for which the variation is being resolved. + * @param {OptimizelyUserContext} user - The user context associated with this decision. + * @returns {DecisionResponse} - A DecisionResponse containing the variation the user is bucketed into, + * along with the decision reasons. */ - getVariation( + private resolveVariation( configObj: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, - options: { [key: string]: boolean } = {} - ): DecisionResponse { + shouldIgnoreUPS: boolean, + userProfileTracker: UserProfileTracker + ): DecisionResponse { const userId = user.getUserId(); const attributes = user.getAttributes(); // by default, the bucketing ID should be the user ID @@ -150,12 +156,10 @@ export class DecisionService { }; } - const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; - const experimentBucketMap = this.resolveExperimentBucketMap(userId, attributes); // check for sticky bucketing if decide options do not include shouldIgnoreUPS if (!shouldIgnoreUPS) { - variation = this.getStoredVariation(configObj, experiment, userId, experimentBucketMap); + variation = this.getStoredVariation(configObj, experiment, userId, userProfileTracker.userProfile); if (variation) { this.logger.log( LOG_LEVEL.INFO, @@ -252,7 +256,7 @@ export class DecisionService { ]); // persist bucketing if decide options do not include shouldIgnoreUPS if (!shouldIgnoreUPS) { - this.saveUserProfile(experiment, variation, userId, experimentBucketMap); + this.updateUserProfile(experiment, variation, userProfileTracker); } return { @@ -261,6 +265,39 @@ export class DecisionService { }; } + /** + * Gets variation where visitor will be bucketed. + * @param {ProjectConfig} configObj The parsed project configuration object + * @param {Experiment} experiment + * @param {OptimizelyUserContext} user A user context + * @param {[key: string]: boolean} options Optional map of decide options + * @return {DecisionResponse} DecisionResponse containing the variation the user is bucketed into + * and the decide reasons. + */ + getVariation( + configObj: ProjectConfig, + experiment: Experiment, + user: OptimizelyUserContext, + options: { [key: string]: boolean } = {} + ): DecisionResponse { + const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; + const userProfileTracker: UserProfileTracker = { + isProfileUpdated: false, + userProfile: null, + } + if(!shouldIgnoreUPS) { + userProfileTracker.userProfile = this.resolveExperimentBucketMap(user.getUserId(), user.getAttributes()); + } + + const result = this.resolveVariation(configObj, experiment, user, shouldIgnoreUPS, userProfileTracker); + + if(!shouldIgnoreUPS) { + this.saveUserProfile(user.getUserId(), userProfileTracker) + } + + return result + } + /** * Merges attributes from attributes[STICKY_BUCKETING_KEY] and userProfileService * @param {string} userId @@ -446,9 +483,9 @@ export class DecisionService { configObj: ProjectConfig, experiment: Experiment, userId: string, - experimentBucketMap: ExperimentBucketMap + experimentBucketMap: ExperimentBucketMap | null ): Variation | null { - if (experimentBucketMap.hasOwnProperty(experiment.id)) { + if (experimentBucketMap?.hasOwnProperty(experiment.id)) { const decision = experimentBucketMap[experiment.id]; const variationId = decision.variation_id; if (configObj.variationIdMap.hasOwnProperty(variationId)) { @@ -497,6 +534,21 @@ export class DecisionService { return null; } + private updateUserProfile( + experiment: Experiment, + variation: Variation, + userProfileTracker: UserProfileTracker + ): void { + if(!userProfileTracker.userProfile) { + return + } + + userProfileTracker.userProfile[experiment.id] = { + variation_id: variation.id + } + userProfileTracker.isProfileUpdated = true + } + /** * Saves the bucketing decision to the user profile * @param {Experiment} experiment @@ -505,31 +557,25 @@ export class DecisionService { * @param {ExperimentBucketMap} experimentBucketMap */ private saveUserProfile( - experiment: Experiment, - variation: Variation, userId: string, - experimentBucketMap: ExperimentBucketMap + userProfileTracker: UserProfileTracker ): void { - if (!this.userProfileService) { + const { userProfile, isProfileUpdated } = userProfileTracker; + + if (!this.userProfileService || !userProfile || !isProfileUpdated) { return; } try { - experimentBucketMap[experiment.id] = { - variation_id: variation.id - }; - this.userProfileService.save({ user_id: userId, - experiment_bucket_map: experimentBucketMap, + experiment_bucket_map: userProfile, }); this.logger.log( LOG_LEVEL.INFO, - LOG_MESSAGES.SAVED_VARIATION, + LOG_MESSAGES.SAVED_USER_VARIATION, MODULE_NAME, - variation.key, - experiment.key, userId, ); } catch (ex: any) { @@ -537,6 +583,74 @@ export class DecisionService { } } + /** + * Determines variations for the specified feature flags. + * + * @param {ProjectConfig} configObj - The parsed project configuration object. + * @param {FeatureFlag[]} featureFlags - The feature flags for which variations are to be determined. + * @param {OptimizelyUserContext} user - The user context associated with this decision. + * @param {Record} options - An optional map of decision options. + * @returns {DecisionResponse[]} - An array of DecisionResponse containing objects with + * experiment, variation, decisionSource properties, and decision reasons. + */ + getVariationsForFeatureList(configObj: ProjectConfig, + featureFlags: FeatureFlag[], + user: OptimizelyUserContext, + options: { [key: string]: boolean } = {}): DecisionResponse[] { + const userId = user.getUserId(); + const attributes = user.getAttributes(); + const decisions: DecisionResponse[] = []; + const userProfileTracker : UserProfileTracker = { + isProfileUpdated: false, + userProfile: null, + } + const shouldIgnoreUPS = options[OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE]; + + if(!shouldIgnoreUPS) { + userProfileTracker.userProfile = this.resolveExperimentBucketMap(userId, attributes); + } + + for(const feature of featureFlags) { + const decideReasons: (string | number)[][] = []; + const decisionVariation = this.getVariationForFeatureExperiment(configObj, feature, user, shouldIgnoreUPS, userProfileTracker); + decideReasons.push(...decisionVariation.reasons); + const experimentDecision = decisionVariation.result; + + if (experimentDecision.variation !== null) { + decisions.push({ + result: experimentDecision, + reasons: decideReasons, + }); + continue; + } + + const decisionRolloutVariation = this.getVariationForRollout(configObj, feature, user); + decideReasons.push(...decisionRolloutVariation.reasons); + const rolloutDecision = decisionRolloutVariation.result; + const userId = user.getUserId(); + + if (rolloutDecision.variation) { + this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key); + decideReasons.push([LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); + } else { + this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key); + decideReasons.push([LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); + } + + decisions.push({ + result: rolloutDecision, + reasons: decideReasons, + }); + } + + if(!shouldIgnoreUPS) { + this.saveUserProfile(userId, userProfileTracker) + } + + return decisions + + } + /** * Given a feature, user ID, and attributes, returns a decision response containing * an object representing a decision and decide reasons. If the user was bucketed into @@ -558,45 +672,15 @@ export class DecisionService { user: OptimizelyUserContext, options: { [key: string]: boolean } = {} ): DecisionResponse { - - const decideReasons: (string | number)[][] = []; - const decisionVariation = this.getVariationForFeatureExperiment(configObj, feature, user, options); - decideReasons.push(...decisionVariation.reasons); - const experimentDecision = decisionVariation.result; - - if (experimentDecision.variation !== null) { - return { - result: experimentDecision, - reasons: decideReasons, - }; - } - - const decisionRolloutVariation = this.getVariationForRollout(configObj, feature, user); - decideReasons.push(...decisionRolloutVariation.reasons); - const rolloutDecision = decisionRolloutVariation.result; - const userId = user.getUserId(); - if (rolloutDecision.variation) { - this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key); - decideReasons.push([LOG_MESSAGES.USER_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); - return { - result: rolloutDecision, - reasons: decideReasons, - }; - } - - this.logger.log(LOG_LEVEL.DEBUG, LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key); - decideReasons.push([LOG_MESSAGES.USER_NOT_IN_ROLLOUT, MODULE_NAME, userId, feature.key]); - return { - result: rolloutDecision, - reasons: decideReasons, - }; + return this.getVariationsForFeatureList(configObj, [feature], user, options)[0] } private getVariationForFeatureExperiment( configObj: ProjectConfig, feature: FeatureFlag, user: OptimizelyUserContext, - options: { [key: string]: boolean } = {} + shouldIgnoreUPS: boolean, + userProfileTracker: UserProfileTracker ): DecisionResponse { const decideReasons: (string | number)[][] = []; @@ -611,7 +695,7 @@ export class DecisionService { for (index = 0; index < feature.experimentIds.length; index++) { const experiment = getExperimentFromId(configObj, feature.experimentIds[index], this.logger); if (experiment) { - decisionVariation = this.getVariationFromExperimentRule(configObj, feature.key, experiment, user, options); + decisionVariation = this.getVariationFromExperimentRule(configObj, feature.key, experiment, user, shouldIgnoreUPS, userProfileTracker); decideReasons.push(...decisionVariation.reasons); variationKey = decisionVariation.result; if (variationKey) { @@ -1108,7 +1192,8 @@ export class DecisionService { flagKey: string, rule: Experiment, user: OptimizelyUserContext, - options: { [key: string]: boolean } = {} + shouldIgnoreUPS: boolean, + userProfileTracker: UserProfileTracker ): DecisionResponse { const decideReasons: (string | number)[][] = []; @@ -1123,7 +1208,7 @@ export class DecisionService { reasons: decideReasons, }; } - const decisionVariation = this.getVariation(configObj, rule, user, options); + const decisionVariation = this.resolveVariation(configObj, rule, user, shouldIgnoreUPS, userProfileTracker); decideReasons.push(...decisionVariation.reasons); const variationKey = decisionVariation.result; diff --git a/lib/core/notification_center/notification_registry.tests.ts b/lib/core/notification_center/notification_registry.tests.ts index 3a99b052c..489abf766 100644 --- a/lib/core/notification_center/notification_registry.tests.ts +++ b/lib/core/notification_center/notification_registry.tests.ts @@ -1,5 +1,5 @@ /** - * Copyright 2023, Optimizely + * Copyright 2023-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,6 @@ * limitations under the License. */ -import { describe, it } from 'mocha'; import { expect } from 'chai'; import { NotificationRegistry } from './notification_registry'; diff --git a/lib/core/odp/odp_event_manager.ts b/lib/core/odp/odp_event_manager.ts index 3b91d7712..627cf0a27 100644 --- a/lib/core/odp/odp_event_manager.ts +++ b/lib/core/odp/odp_event_manager.ts @@ -46,7 +46,7 @@ export interface IOdpEventManager { stop(): Promise; - registerVuid(vuid: string): void; + sendInitializedEvent(vuid: string): void; identifyUser(userId?: string, vuid?: string): void; @@ -251,7 +251,7 @@ export abstract class OdpEventManager implements IOdpEventManager { * Register a new visitor user id (VUID) in ODP * @param vuid Visitor User ID to send */ - registerVuid(vuid: string): void { + sendInitializedEvent(vuid: string): void { const identifiers = new Map(); identifiers.set(ODP_USER_KEY.VUID, vuid); diff --git a/lib/core/odp/odp_manager.ts b/lib/core/odp/odp_manager.ts index 54278358d..e57909d31 100644 --- a/lib/core/odp/odp_manager.ts +++ b/lib/core/odp/odp_manager.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import { LOG_MESSAGES } from './../../utils/enums/index'; -import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; +import { LogHandler, LogLevel } from '../../modules/logging'; import { ERROR_MESSAGES, ODP_USER_KEY } from '../../utils/enums'; import { VuidManager } from '../../plugins/vuid_manager'; -import { OdpConfig, OdpIntegrationConfig, odpIntegrationsAreEqual } from './odp_config'; +import { OdpIntegrationConfig, odpIntegrationsAreEqual } from './odp_config'; import { IOdpEventManager } from './odp_event_manager'; import { IOdpSegmentManager } from './odp_segment_manager'; import { OptimizelySegmentOption } from './optimizely_segment_option'; @@ -47,9 +46,7 @@ export interface IOdpManager { sendEvent({ type, action, identifiers, data }: OdpEvent): void; - isVuidEnabled(): boolean; - - getVuid(): string | undefined; + setVuid(vuid: string): void; } export enum Status { @@ -72,32 +69,33 @@ export abstract class OdpManager implements IOdpManager { */ private configPromise: ResolvablePromise; - status: Status = Status.Stopped; + private status: Status = Status.Stopped; /** * ODP Segment Manager which provides an interface to the remote ODP server (GraphQL API) for audience segments mapping. * It fetches all qualified segments for the given user context and manages the segments cache for all user contexts. */ - private segmentManager: IOdpSegmentManager; + private readonly segmentManager: IOdpSegmentManager; /** * ODP Event Manager which provides an interface to the remote ODP server (REST API) for events. * It will queue all pending events (persistent) and send them (in batches of up to 10 events) to the ODP server when possible. */ - private eventManager: IOdpEventManager; + protected readonly eventManager: IOdpEventManager; /** * Handler for recording execution logs * @protected */ - protected logger: LogHandler; + protected readonly logger: LogHandler; /** * ODP configuration settings for identifying the target API and segments */ - odpIntegrationConfig?: OdpIntegrationConfig; + protected odpIntegrationConfig?: OdpIntegrationConfig; + + protected vuid?: string; - // TODO: Consider accepting logger as a parameter and initializing it in constructor instead constructor({ odpIntegrationConfig, segmentManager, @@ -112,22 +110,14 @@ export abstract class OdpManager implements IOdpManager { this.segmentManager = segmentManager; this.eventManager = eventManager; this.logger = logger; - this.configPromise = resolvablePromise(); const readinessDependencies: PromiseLike[] = [this.configPromise]; - if (this.isVuidEnabled()) { - readinessDependencies.push(this.initializeVuid()); - } - this.initPromise = Promise.all(readinessDependencies); this.onReady().then(() => { this.ready = true; - if (this.isVuidEnabled() && this.status === Status.Running) { - this.registerVuid(); - } }); if (odpIntegrationConfig) { @@ -135,7 +125,26 @@ export abstract class OdpManager implements IOdpManager { } } - public getStatus(): Status { + setVuid(vuid: string): void { + if (!this.odpIntegrationConfig) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); + return; + } + + if (!this.odpIntegrationConfig.integrated) { + this.logger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_INTEGRATED); + return; + } + + try { + this.vuid = vuid; + this.eventManager.sendInitializedEvent(vuid); + } catch (e) { + this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_VUID_REGISTRATION_FAILED); + } + } + + getStatus(): Status { return this.status; } @@ -281,43 +290,11 @@ export abstract class OdpManager implements IOdpManager { throw new Error('ODP action is not valid (cannot be empty).'); } - this.eventManager.sendEvent(new OdpEvent(mType, action, identifiers, data)); - } - - /** - * Identifies if the VUID feature is enabled - */ - abstract isVuidEnabled(): boolean; - - /** - * Returns VUID value if it exists - */ - abstract getVuid(): string | undefined; - - protected initializeVuid(): Promise { - return Promise.resolve(); - } - - private registerVuid() { - if (!this.odpIntegrationConfig) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_CONFIG_NOT_AVAILABLE); - return; - } - - if (!this.odpIntegrationConfig.integrated) { - this.logger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_INTEGRATED); - return; - } - - const vuid = this.getVuid(); - if (!vuid) { - return; + if (this.vuid) { + identifiers = new Map(identifiers); + identifiers.set(ODP_USER_KEY.VUID, this.vuid); } - try { - this.eventManager.registerVuid(vuid); - } catch (e) { - this.logger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_VUID_REGISTRATION_FAILED); - } + this.eventManager.sendEvent(new OdpEvent(mType, action, identifiers, data)); } } diff --git a/lib/index.browser.tests.js b/lib/index.browser.tests.js index e14b91463..4ea4d827e 100644 --- a/lib/index.browser.tests.js +++ b/lib/index.browser.tests.js @@ -193,7 +193,7 @@ describe('javascript-sdk (Browser)', function() { optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); - assert.equal(optlyInstance.clientVersion, '5.3.4'); + assert.equal(optlyInstance.clientVersion, '6.0.0'); }); it('should set the JavaScript client engine and version', function() { @@ -654,38 +654,6 @@ describe('javascript-sdk (Browser)', function() { sinon.assert.calledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.INFO, LOG_MESSAGES.ODP_DISABLED); }); - it('should include the VUID instantation promise of Browser ODP Manager in the Optimizely client onReady promise dependency array', () => { - const client = optimizelyFactory.createInstance({ - datafile: testData.getTestProjectConfigWithFeatures(), - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpManager: BrowserOdpManager.createInstance({ - logger, - }), - }); - - client - .onReady() - .then(() => { - assert.isDefined(client.odpManager.initPromise); - client.odpManager.initPromise - .then(() => { - assert.isTrue(true); - }) - .catch(() => { - assert.isTrue(false); - }); - assert.isDefined(client.odpManager.getVuid()); - }) - .catch(() => { - assert.isTrue(false); - }); - - sinon.assert.neverCalledWith(logger.log, optimizelyFactory.enums.LOG_LEVEL.ERROR); - }); - it('should accept a valid custom cache size', () => { const client = optimizelyFactory.createInstance({ datafile: testData.getTestProjectConfigWithFeatures(), @@ -774,7 +742,6 @@ describe('javascript-sdk (Browser)', function() { }); const readyData = await client.onReady(); - sinon.assert.called(fakeSegmentManager.updateSettings); assert.equal(readyData.success, true); @@ -885,6 +852,7 @@ describe('javascript-sdk (Browser)', function() { client.sendOdpEvent('test', '', new Map([['eamil', 'test@test.test']]), new Map([['key', 'value']])); clock.tick(10000); + await Promise.resolve(); const eventRequestUrl = new URL(fakeRequestHandler.makeRequest.lastCall.args[0]); const searchParams = eventRequestUrl.searchParams; @@ -1090,7 +1058,7 @@ describe('javascript-sdk (Browser)', function() { assert(client.odpManager.eventManager.batchSize, 1); }); - it('should send an odp event to the browser endpoint', async () => { + it('should send a client_initialized odp event to the browser endpoint', async () => { const odpConfig = new OdpConfig(); const apiManager = new BrowserOdpEventApiManager(mockRequestHandler, logger); @@ -1109,6 +1077,7 @@ describe('javascript-sdk (Browser)', function() { errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, eventBatchSize: null, + vuidOptions: { enableVuid: true }, logger, odpOptions: { odpConfig, @@ -1120,10 +1089,10 @@ describe('javascript-sdk (Browser)', function() { assert.equal(readyData.success, true); assert.isUndefined(readyData.reason); - client.sendOdpEvent(ODP_EVENT_ACTION.INITIALIZED); // wait for request to be sent - clock.tick(100); + clock.tick(10000); + await Promise.resolve(); let publicKey = datafile.integrations[0].publicKey; let pixelUrl = datafile.integrations[0].pixelUrl; @@ -1146,41 +1115,6 @@ describe('javascript-sdk (Browser)', function() { sinon.assert.notCalled(logger.error); }); - - it('should send odp client_initialized on client instantiation', async () => { - const odpConfig = new OdpConfig('key', 'host', 'pixel', []); - const apiManager = new BrowserOdpEventApiManager(mockRequestHandler, logger); - sinon.spy(apiManager, 'sendEvents'); - const eventManager = new BrowserOdpEventManager({ - odpConfig, - apiManager, - logger, - }); - const datafile = testData.getOdpIntegratedConfigWithSegments(); - const client = optimizelyFactory.createInstance({ - datafile, - errorHandler: fakeErrorHandler, - eventDispatcher: fakeEventDispatcher, - eventBatchSize: null, - logger, - odpOptions: { - odpConfig, - eventManager, - }, - }); - - const readyData = await client.onReady(); - assert.equal(readyData.success, true); - assert.isUndefined(readyData.reason); - - clock.tick(100); - - const [_, events] = apiManager.sendEvents.getCall(0).args; - - const [firstEvent] = events; - assert.equal(firstEvent.action, 'client_initialized'); - assert.equal(firstEvent.type, 'fullstack'); - }); }); }); }); diff --git a/lib/index.browser.ts b/lib/index.browser.ts index c0d62897c..002d42ed5 100644 --- a/lib/index.browser.ts +++ b/lib/index.browser.ts @@ -33,6 +33,7 @@ import Optimizely from './optimizely'; import { IUserAgentParser } from './core/odp/user_agent_parser'; import { getUserAgentParser } from './plugins/odp/user_agent_parser/index.browser'; import * as commonExports from './common_exports'; +import { vuidManager } from './plugins/vuid_manager/index.browser'; const logger = getLogger(); logHelper.setLogHandler(loggerPlugin.createLogger()); @@ -146,6 +147,8 @@ const createInstance = function(config: Config): Client | null { isValidInstance, odpManager: odpExplicitlyOff ? undefined : BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), + vuidOptions: config.vuidOptions, + vuidManager, }; const optimizely = new Optimizely(optimizelyOptions); diff --git a/lib/index.lite.tests.js b/lib/index.lite.tests.js index 30282dcf5..04533a89d 100644 --- a/lib/index.lite.tests.js +++ b/lib/index.lite.tests.js @@ -76,7 +76,7 @@ describe('optimizelyFactory', function() { optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); - assert.equal(optlyInstance.clientVersion, '5.3.4'); + assert.equal(optlyInstance.clientVersion, '6.0.0'); }); }); }); diff --git a/lib/index.node.tests.js b/lib/index.node.tests.js index 98aac4c97..e54423f06 100644 --- a/lib/index.node.tests.js +++ b/lib/index.node.tests.js @@ -90,7 +90,7 @@ describe('optimizelyFactory', function() { optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); - assert.equal(optlyInstance.clientVersion, '5.3.4'); + assert.equal(optlyInstance.clientVersion, '6.0.0'); }); describe('event processor configuration', function() { diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index ee5a1975c..2af169f88 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -28,7 +28,7 @@ import { OptimizelyDecideOption, Client, Config } from './shared_types'; import { createHttpPollingDatafileManager } from './plugins/datafile_manager/react_native_http_polling_datafile_manager'; import { BrowserOdpManager } from './plugins/odp_manager/index.browser'; import * as commonExports from './common_exports'; - +import { vuidManager } from './plugins/vuid_manager/index.react_native'; import 'fast-text-encoding'; import 'react-native-get-random-values'; @@ -46,7 +46,7 @@ const DEFAULT_EVENT_MAX_QUEUE_SIZE = 10000; * @return {Client|null} the Optimizely client object * null on error */ -const createInstance = function(config: Config): Client | null { +const createInstance = function (config: Config): Client | null { try { // TODO warn about setting per instance errorHandler / logger / logLevel let isValidInstance = false; @@ -126,7 +126,9 @@ const createInstance = function(config: Config): Client | null { notificationCenter, isValidInstance: isValidInstance, odpManager: odpExplicitlyOff ? undefined - :BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), + : BrowserOdpManager.createInstance({ logger, odpOptions: config.odpOptions, clientEngine, clientVersion }), + vuidOptions: config.vuidOptions, + vuidManager, }; // If client engine is react, convert it to react native. diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index 3f5b3e232..af8b3fe08 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -4627,7 +4627,7 @@ describe('lib/optimizely', function() { sinon.assert.calledOnce(errorHandler.handleError); var errorMessage = errorHandler.handleError.lastCall.args[0].message; assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_INPUT_FORMAT, 'OPTIMIZELY', 'user_id')); - sinon.assert.calledOnce(createdLogger.log); + sinon.assert.calledTwice(createdLogger.log); var logMessage = buildLogMessageFromArgs(createdLogger.log.args[0]); assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.INVALID_INPUT_FORMAT, 'OPTIMIZELY', 'user_id')); }); @@ -4637,7 +4637,7 @@ describe('lib/optimizely', function() { sinon.assert.calledOnce(errorHandler.handleError); var errorMessage = errorHandler.handleError.lastCall.args[0].message; assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_ATTRIBUTES, 'ATTRIBUTES_VALIDATOR')); - sinon.assert.calledOnce(createdLogger.log); + sinon.assert.calledTwice(createdLogger.log); var logMessage = buildLogMessageFromArgs(createdLogger.log.args[0]); assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.INVALID_ATTRIBUTES, 'ATTRIBUTES_VALIDATOR')); }); @@ -5903,6 +5903,81 @@ describe('lib/optimizely', function() { assert.deepEqual(decision, expectedDecision); sinon.assert.calledTwice(eventDispatcher.dispatchEvent); }); + describe('UPS Batching', function() { + var userProfileServiceInstance = { + lookup: function() {}, + save: function() {}, + }; + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestDecideProjectConfig(), + userProfileService: userProfileServiceInstance, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + eventBatchSize: 1, + defaultDecideOptions: [], + notificationCenter, + eventProcessor, + }); + + sinon.stub(optlyInstance.decisionService.userProfileService, 'lookup') + sinon.stub(optlyInstance.decisionService.userProfileService, 'save') + // + }); + + it('Should call UPS methods only once', function() { + var flagKeysArray = ['feature_1', 'feature_2']; + var user = optlyInstance.createUserContext(userId); + var expectedVariables1 = optlyInstance.getAllFeatureVariables(flagKeysArray[0], userId); + var expectedVariables2 = optlyInstance.getAllFeatureVariables(flagKeysArray[1], userId); + optlyInstance.decisionService.userProfileService.save.resetHistory(); + optlyInstance.decisionService.userProfileService.lookup.resetHistory(); + var decisionsMap = optlyInstance.decideForKeys(user, flagKeysArray); + var decision1 = decisionsMap[flagKeysArray[0]]; + var decision2 = decisionsMap[flagKeysArray[1]]; + var expectedDecision1 = { + variationKey: '18257766532', + enabled: true, + variables: expectedVariables1, + ruleKey: '18322080788', + flagKey: flagKeysArray[0], + userContext: user, + reasons: [], + }; + var expectedDecision2 = { + variationKey: 'variation_with_traffic', + enabled: true, + variables: expectedVariables2, + ruleKey: 'exp_no_audience', + flagKey: flagKeysArray[1], + userContext: user, + reasons: [], + }; + var userProfile = { + user_id: userId, + experiment_bucket_map: { + '10420810910': { // ruleKey from expectedDecision1 + variation_id: '10418551353' // variationKey from expectedDecision1 + } + } + }; + + assert.deepEqual(Object.values(decisionsMap).length, 2); + assert.deepEqual(decision1, expectedDecision1); + assert.deepEqual(decision2, expectedDecision2); + // UPS batch assertion + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.lookup); + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.save); + + // UPS save assertion + sinon.assert.calledWithExactly(optlyInstance.decisionService.userProfileService.save, userProfile); + }); + }) + }); describe('#decideAll', function() { @@ -6096,6 +6171,69 @@ describe('lib/optimizely', function() { sinon.assert.calledThrice(eventDispatcher.dispatchEvent); }); }); + + describe('UPS batching', function() { + beforeEach(function() { + var userProfileServiceInstance = { + lookup: function() {}, + save: function() {}, + }; + + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestDecideProjectConfig(), + userProfileService: userProfileServiceInstance, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + isValidInstance: true, + eventBatchSize: 1, + defaultDecideOptions: [OptimizelyDecideOption.ENABLED_FLAGS_ONLY], + eventProcessor, + notificationCenter, + }); + + sinon.stub(optlyInstance.decisionService.userProfileService, 'lookup') + sinon.stub(optlyInstance.decisionService.userProfileService, 'save') + }); + + it('should call UPS methods only once', function() { + var flagKey1 = 'feature_1'; + var flagKey2 = 'feature_2'; + var user = optlyInstance.createUserContext(userId, { gender: 'female' }); + var decisionsMap = optlyInstance.decideAll(user, [OptimizelyDecideOption.EXCLUDE_VARIABLES]); + var decision1 = decisionsMap[flagKey1]; + var decision2 = decisionsMap[flagKey2]; + var expectedDecision1 = { + variationKey: '18257766532', + enabled: true, + variables: {}, + ruleKey: '18322080788', + flagKey: flagKey1, + userContext: user, + reasons: [], + }; + var expectedDecision2 = { + variationKey: 'variation_with_traffic', + enabled: true, + variables: {}, + ruleKey: 'exp_no_audience', + flagKey: flagKey2, + userContext: user, + reasons: [], + }; + + // Decision assertion + assert.deepEqual(Object.values(decisionsMap).length, 2); + assert.deepEqual(decision1, expectedDecision1); + assert.deepEqual(decision2, expectedDecision2); + + // UPS batch assertion + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.lookup); + sinon.assert.calledOnce(optlyInstance.decisionService.userProfileService.save); + }) + }); }); }); @@ -9947,9 +10085,9 @@ describe('lib/optimizely', function() { eventProcessor, }); return optlyInstance.onReady().then(function() { - sinon.assert.calledOnce(clock.setTimeout); + // sinon.assert.calledOnce(clock.setTimeout); var timeout = clock.setTimeout.getCall(0).returnValue; - sinon.assert.calledOnce(clock.clearTimeout); + // sinon.assert.calledOnce(clock.clearTimeout); sinon.assert.calledWithExactly(clock.clearTimeout, timeout); }); }); diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 8605adec0..42a4dc20d 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -20,7 +20,6 @@ import { NotificationCenter } from '../core/notification_center'; import { EventProcessor } from '../modules/event_processor'; import { IOdpManager } from '../core/odp/odp_manager'; -import { OdpConfig } from '../core/odp/odp_config'; import { OdpEvent } from '../core/odp/odp_event'; import { OptimizelySegmentOption } from '../core/odp/optimizely_segment_option'; @@ -38,6 +37,7 @@ import { FeatureVariableValue, OptimizelyDecision, Client, + VuidOptions, } from '../shared_types'; import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; @@ -69,6 +69,7 @@ import { FS_USER_ID_ALIAS, ODP_USER_KEY, } from '../utils/enums'; +import { IVuidManager } from '../plugins/vuid_manager'; const MODULE_NAME = 'OPTIMIZELY'; @@ -79,6 +80,8 @@ type InputKey = 'feature_key' | 'user_id' | 'variable_key' | 'experiment_key' | type StringInputs = Partial>; +type DecisionReasons = (string | number)[]; + export default class Optimizely implements Client { private isOptimizelyConfigValid: boolean; private disposeOnUpdate: (() => void) | null; @@ -96,6 +99,8 @@ export default class Optimizely implements Client { private eventProcessor: EventProcessor; private defaultDecideOptions: { [key: string]: boolean }; protected odpManager?: IOdpManager; + private vuidOptions?: VuidOptions; + protected vuidManager?: IVuidManager; public notificationCenter: NotificationCenter; constructor(config: OptimizelyOptions) { @@ -111,6 +116,8 @@ export default class Optimizely implements Client { this.isOptimizelyConfigValid = config.isValidInstance; this.logger = config.logger; this.odpManager = config.odpManager; + this.vuidOptions = config.vuidOptions; + this.vuidManager = config.vuidManager; let decideOptionsArray = config.defaultDecideOptions ?? []; if (!Array.isArray(decideOptionsArray)) { @@ -179,6 +186,7 @@ export default class Optimizely implements Client { projectConfigManagerReadyPromise, eventProcessorStartedPromise, config.odpManager ? config.odpManager.onReady() : Promise.resolve(), + config.vuidManager ? config.vuidManager.configure(this.vuidOptions ?? { enableVuid: false }) : Promise.resolve(), ]).then(promiseResults => { // Only return status from project config promise because event processor promise does not return any status. return promiseResults[0]; @@ -186,6 +194,15 @@ export default class Optimizely implements Client { this.readyTimeouts = {}; this.nextReadyTimeoutId = 0; + + this.onReady().then(({ success }) => { + if (success) { + const vuid = this.vuidManager?.vuid; + if (vuid) { + this.odpManager?.setVuid(vuid); + } + } + }); } /** @@ -1429,6 +1446,7 @@ export default class Optimizely implements Client { //============ decide ============// + /** * Creates a context of the user for which decision APIs will be called. * @@ -1441,9 +1459,10 @@ export default class Optimizely implements Client { * null if provided inputs are invalid */ createUserContext(userId?: string, attributes?: UserAttributes): OptimizelyUserContext | null { - const userIdentifier = userId ?? this.odpManager?.getVuid(); + const userIdentifier = userId ?? this.vuidManager?.vuid; if (userIdentifier === undefined || !this.validateInputs({ user_id: userIdentifier }, attributes)) { + this.logger.log(LOG_LEVEL.ERROR, '%s: Valid User ID or VUID not provided. User context not created.', MODULE_NAME); return null; } @@ -1476,105 +1495,14 @@ export default class Optimizely implements Client { } decide(user: OptimizelyUserContext, key: string, options: OptimizelyDecideOption[] = []): OptimizelyDecision { - const userId = user.getUserId(); - const attributes = user.getAttributes(); const configObj = this.projectConfigManager.getConfig(); - const reasons: (string | number)[][] = []; - let decisionObj: DecisionObj; + if (!this.isValidInstance() || !configObj) { this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'decide'); return newErrorDecision(key, user, [DECISION_MESSAGES.SDK_NOT_READY]); } - const feature = configObj.featureKeyMap[key]; - if (!feature) { - this.logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.FEATURE_NOT_IN_DATAFILE, MODULE_NAME, key); - return newErrorDecision(key, user, [sprintf(DECISION_MESSAGES.FLAG_KEY_INVALID, key)]); - } - - const allDecideOptions = this.getAllDecideOptions(options); - - const forcedDecisionResponse = this.decisionService.findValidatedForcedDecision(configObj, user, key); - reasons.push(...forcedDecisionResponse.reasons); - const variation = forcedDecisionResponse.result; - if (variation) { - decisionObj = { - experiment: null, - variation: variation, - decisionSource: DECISION_SOURCES.FEATURE_TEST, - }; - } else { - const decisionVariation = this.decisionService.getVariationForFeature(configObj, feature, user, allDecideOptions); - reasons.push(...decisionVariation.reasons); - decisionObj = decisionVariation.result; - } - const decisionSource = decisionObj.decisionSource; - const experimentKey = decisionObj.experiment?.key ?? null; - const variationKey = decisionObj.variation?.key ?? null; - const flagEnabled: boolean = decision.getFeatureEnabledFromVariation(decisionObj); - if (flagEnabled === true) { - this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, key, userId); - } else { - this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, key, userId); - } - - const variablesMap: { [key: string]: unknown } = {}; - let decisionEventDispatched = false; - - if (!allDecideOptions[OptimizelyDecideOption.EXCLUDE_VARIABLES]) { - feature.variables.forEach(variable => { - variablesMap[variable.key] = this.getFeatureVariableValueFromVariation( - key, - flagEnabled, - decisionObj.variation, - variable, - userId - ); - }); - } - - if ( - !allDecideOptions[OptimizelyDecideOption.DISABLE_DECISION_EVENT] && - (decisionSource === DECISION_SOURCES.FEATURE_TEST || - (decisionSource === DECISION_SOURCES.ROLLOUT && projectConfig.getSendFlagDecisionsValue(configObj))) - ) { - this.sendImpressionEvent(decisionObj, key, userId, flagEnabled, attributes); - decisionEventDispatched = true; - } - - const shouldIncludeReasons = allDecideOptions[OptimizelyDecideOption.INCLUDE_REASONS]; - - let reportedReasons: string[] = []; - if (shouldIncludeReasons) { - reportedReasons = reasons.map(reason => sprintf(reason[0] as string, ...reason.slice(1))); - } - - const featureInfo = { - flagKey: key, - enabled: flagEnabled, - variationKey: variationKey, - ruleKey: experimentKey, - variables: variablesMap, - reasons: reportedReasons, - decisionEventDispatched: decisionEventDispatched, - }; - - this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, { - type: DECISION_NOTIFICATION_TYPES.FLAG, - userId: userId, - attributes: attributes, - decisionInfo: featureInfo, - }); - - return { - variationKey: variationKey, - enabled: flagEnabled, - variables: variablesMap, - ruleKey: experimentKey, - flagKey: key, - userContext: user, - reasons: reportedReasons, - }; + return this.decideForKeys(user, [key], options, true)[key]; } /** @@ -1600,6 +1528,98 @@ export default class Optimizely implements Client { return allDecideOptions; } + /** + * Makes a decision for a given feature key. + * + * @param {OptimizelyUserContext} user - The user context associated with this Optimizely client. + * @param {string} key - The feature key for which a decision will be made. + * @param {DecisionObj} decisionObj - The decision object containing decision details. + * @param {DecisionReasons[]} reasons - An array of reasons for the decision. + * @param {Record} options - A map of options for decision-making. + * @param {projectConfig.ProjectConfig} configObj - The project configuration object. + * @returns {OptimizelyDecision} - The decision object for the feature flag. + */ + private generateDecision( + user: OptimizelyUserContext, + key: string, + decisionObj: DecisionObj, + reasons: DecisionReasons[], + options: Record, + configObj: projectConfig.ProjectConfig, + ): OptimizelyDecision { + const userId = user.getUserId() + const attributes = user.getAttributes() + const feature = configObj.featureKeyMap[key] + const decisionSource = decisionObj.decisionSource; + const experimentKey = decisionObj.experiment?.key ?? null; + const variationKey = decisionObj.variation?.key ?? null; + const flagEnabled: boolean = decision.getFeatureEnabledFromVariation(decisionObj); + const variablesMap: { [key: string]: unknown } = {}; + let decisionEventDispatched = false; + + if (flagEnabled) { + this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, key, userId); + } else { + this.logger.log(LOG_LEVEL.INFO, LOG_MESSAGES.FEATURE_NOT_ENABLED_FOR_USER, MODULE_NAME, key, userId); + } + + + if (!options[OptimizelyDecideOption.EXCLUDE_VARIABLES]) { + feature.variables.forEach(variable => { + variablesMap[variable.key] = this.getFeatureVariableValueFromVariation( + key, + flagEnabled, + decisionObj.variation, + variable, + userId + ); + }); + } + + if ( + !options[OptimizelyDecideOption.DISABLE_DECISION_EVENT] && + (decisionSource === DECISION_SOURCES.FEATURE_TEST || + (decisionSource === DECISION_SOURCES.ROLLOUT && projectConfig.getSendFlagDecisionsValue(configObj))) + ) { + this.sendImpressionEvent(decisionObj, key, userId, flagEnabled, attributes); + decisionEventDispatched = true; + } + + const shouldIncludeReasons = options[OptimizelyDecideOption.INCLUDE_REASONS]; + + let reportedReasons: string[] = []; + if (shouldIncludeReasons) { + reportedReasons = reasons.map(reason => sprintf(reason[0] as string, ...reason.slice(1))); + } + + const featureInfo = { + flagKey: key, + enabled: flagEnabled, + variationKey: variationKey, + ruleKey: experimentKey, + variables: variablesMap, + reasons: reportedReasons, + decisionEventDispatched: decisionEventDispatched, + }; + + this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.DECISION, { + type: DECISION_NOTIFICATION_TYPES.FLAG, + userId: userId, + attributes: attributes, + decisionInfo: featureInfo, + }); + + return { + variationKey: variationKey, + enabled: flagEnabled, + variables: variablesMap, + ruleKey: experimentKey, + flagKey: key, + userContext: user, + reasons: reportedReasons, + }; + } + /** * Returns an object of decision results for multiple flag keys and a user context. * If the SDK finds an error for a key, the response will include a decision for the key showing reasons for the error. @@ -1612,10 +1632,18 @@ export default class Optimizely implements Client { decideForKeys( user: OptimizelyUserContext, keys: string[], - options: OptimizelyDecideOption[] = [] - ): { [key: string]: OptimizelyDecision } { - const decisionMap: { [key: string]: OptimizelyDecision } = {}; - if (!this.isValidInstance()) { + options: OptimizelyDecideOption[] = [], + ignoreEnabledFlagOption?:boolean + ): Record { + const decisionMap: Record = {}; + const flagDecisions: Record = {}; + const decisionReasonsMap: Record = {}; + const flagsWithoutForcedDecision = []; + const validKeys = []; + + const configObj = this.projectConfigManager.getConfig() + + if (!this.isValidInstance() || !configObj) { this.logger.log(LOG_LEVEL.ERROR, LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'decideForKeys'); return decisionMap; } @@ -1624,12 +1652,51 @@ export default class Optimizely implements Client { } const allDecideOptions = this.getAllDecideOptions(options); - keys.forEach(key => { - const optimizelyDecision: OptimizelyDecision = this.decide(user, key, options); - if (!allDecideOptions[OptimizelyDecideOption.ENABLED_FLAGS_ONLY] || optimizelyDecision.enabled) { - decisionMap[key] = optimizelyDecision; + + if (ignoreEnabledFlagOption) { + delete allDecideOptions[OptimizelyDecideOption.ENABLED_FLAGS_ONLY]; + } + + for(const key of keys) { + const feature = configObj.featureKeyMap[key]; + if (!feature) { + this.logger.log(LOG_LEVEL.ERROR, ERROR_MESSAGES.FEATURE_NOT_IN_DATAFILE, MODULE_NAME, key); + decisionMap[key] = newErrorDecision(key, user, [sprintf(DECISION_MESSAGES.FLAG_KEY_INVALID, key)]); + continue } - }); + + validKeys.push(key); + const forcedDecisionResponse = this.decisionService.findValidatedForcedDecision(configObj, user, key); + decisionReasonsMap[key] = forcedDecisionResponse.reasons + const variation = forcedDecisionResponse.result; + + if (variation) { + flagDecisions[key] = { + experiment: null, + variation: variation, + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }; + } else { + flagsWithoutForcedDecision.push(feature) + } + } + + const decisionList = this.decisionService.getVariationsForFeatureList(configObj, flagsWithoutForcedDecision, user, allDecideOptions); + + for(let i = 0; i < flagsWithoutForcedDecision.length; i++) { + const key = flagsWithoutForcedDecision[i].key; + const decision = decisionList[i]; + flagDecisions[key] = decision.result; + decisionReasonsMap[key] = [...decisionReasonsMap[key], ...decision.reasons]; + } + + for(const validKey of validKeys) { + const decision = this.generateDecision(user, validKey, flagDecisions[validKey], decisionReasonsMap[validKey], allDecideOptions, configObj); + + if(!allDecideOptions[OptimizelyDecideOption.ENABLED_FLAGS_ONLY] || decision.enabled) { + decisionMap[validKey] = decision; + } + } return decisionMap; } @@ -1759,16 +1826,10 @@ export default class Optimizely implements Client { * ODP Manager has not been instantiated yet for any reason. */ public getVuid(): string | undefined { - if (!this.odpManager) { - this.logger?.error('Unable to get VUID - ODP Manager is not instantiated yet.'); - return undefined; - } - - if (!this.odpManager.isVuidEnabled()) { - this.logger.log(LOG_LEVEL.WARNING, 'getVuid() unavailable for this platform', MODULE_NAME); - return undefined; + if (!this.vuidManager?.vuidEnabled) { + this.logger.log(LOG_LEVEL.WARNING, 'getVuid() unavailable for this platform or was not explicitly enabled.', MODULE_NAME); } - return this.odpManager.getVuid(); + return this.vuidManager?.vuid; } } diff --git a/lib/optimizely_user_context/index.tests.js b/lib/optimizely_user_context/index.tests.js index 2b6ce0653..12da11aef 100644 --- a/lib/optimizely_user_context/index.tests.js +++ b/lib/optimizely_user_context/index.tests.js @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2020-2023, Optimizely, Inc. and contributors * + * Copyright 2020-2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -35,7 +35,7 @@ describe('lib/optimizely_user_context', function() { describe('APIs', function() { var fakeOptimizely; var userId = 'tester'; - var options = 'fakeOption'; + var options = ['fakeOption']; describe('#setAttribute', function() { fakeOptimizely = { decide: sinon.stub().returns({}), diff --git a/lib/plugins/odp_manager/index.browser.ts b/lib/plugins/odp_manager/index.browser.ts index e7095364a..c04f27c4b 100644 --- a/lib/plugins/odp_manager/index.browser.ts +++ b/lib/plugins/odp_manager/index.browser.ts @@ -18,23 +18,18 @@ import { CLIENT_VERSION, ERROR_MESSAGES, JAVASCRIPT_CLIENT_ENGINE, - ODP_USER_KEY, REQUEST_TIMEOUT_ODP_SEGMENTS_MS, REQUEST_TIMEOUT_ODP_EVENTS_MS, - LOG_MESSAGES, } from '../../utils/enums'; import { getLogger, LogHandler, LogLevel } from '../../modules/logging'; import { BrowserRequestHandler } from './../../utils/http_request_handler/browser_request_handler'; -import BrowserAsyncStorageCache from '../key_value_cache/browserAsyncStorageCache'; -import PersistentKeyValueCache from '../key_value_cache/persistentKeyValueCache'; import { BrowserLRUCache } from '../../utils/lru_cache'; import { VuidManager } from './../vuid_manager/index'; import { OdpManager } from '../../core/odp/odp_manager'; -import { OdpEvent } from '../../core/odp/odp_event'; import { IOdpEventManager, OdpOptions } from '../../shared_types'; import { BrowserOdpEventApiManager } from '../odp/event_api_manager/index.browser'; import { BrowserOdpEventManager } from '../odp/event_manager/index.browser'; @@ -52,10 +47,6 @@ interface BrowserOdpManagerConfig { // Client-side Browser Plugin for ODP Manager export class BrowserOdpManager extends OdpManager { - static cache = new BrowserAsyncStorageCache(); - vuidManager?: VuidManager; - vuid?: string; - constructor(options: { odpIntegrationConfig?: OdpIntegrationConfig; segmentManager: IOdpSegmentManager; @@ -90,7 +81,6 @@ export class BrowserOdpManager extends OdpManager { } let segmentManager: IOdpSegmentManager; - if (odpOptions?.segmentManager) { segmentManager = odpOptions.segmentManager; } else { @@ -118,7 +108,7 @@ export class BrowserOdpManager extends OdpManager { } let eventManager: IOdpEventManager; - + if (odpOptions?.eventManager) { eventManager = odpOptions.eventManager; } else { @@ -143,15 +133,6 @@ export class BrowserOdpManager extends OdpManager { }); } - /** - * @override - * accesses or creates new VUID from Browser cache - */ - protected async initializeVuid(): Promise { - const vuidManager = await VuidManager.instance(BrowserOdpManager.cache); - this.vuid = vuidManager.vuid; - } - /** * @override * - Still identifies a user via the ODP Event Manager @@ -169,35 +150,6 @@ export class BrowserOdpManager extends OdpManager { return; } - super.identifyUser(fsUserId, vuid || this.vuid); - } - - /** - * @override - * - Sends an event to the ODP Server via the ODP Events API - * - Intercepts identifiers and injects VUID before sending event - * - Identifiers must contain at least one key-value pair - * @param {OdpEvent} odpEvent > ODP Event to send to event manager - */ - sendEvent({ type, action, identifiers, data }: OdpEvent): void { - const identifiersWithVuid = new Map(identifiers); - - if (!identifiers.has(ODP_USER_KEY.VUID)) { - if (this.vuid) { - identifiersWithVuid.set(ODP_USER_KEY.VUID, this.vuid); - } else { - throw new Error(ERROR_MESSAGES.ODP_SEND_EVENT_FAILED_VUID_MISSING); - } - } - - super.sendEvent({ type, action, identifiers: identifiersWithVuid, data }); - } - - isVuidEnabled(): boolean { - return true; - } - - getVuid(): string | undefined { - return this.vuid; + super.identifyUser(fsUserId, vuid); } } diff --git a/lib/plugins/odp_manager/index.node.ts b/lib/plugins/odp_manager/index.node.ts index bdd57f1ad..181b5d546 100644 --- a/lib/plugins/odp_manager/index.node.ts +++ b/lib/plugins/odp_manager/index.node.ts @@ -133,12 +133,4 @@ export class NodeOdpManager extends OdpManager { logger, }); } - - public isVuidEnabled(): boolean { - return false; - } - - public getVuid(): string | undefined { - return undefined; - } } diff --git a/lib/plugins/vuid_manager/index.browser.ts b/lib/plugins/vuid_manager/index.browser.ts new file mode 100644 index 000000000..722a037d5 --- /dev/null +++ b/lib/plugins/vuid_manager/index.browser.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2024, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { VuidManager } from "."; +import BrowserAsyncStorageCache from "../key_value_cache/browserAsyncStorageCache"; + +export const vuidManager = new VuidManager(new BrowserAsyncStorageCache()); diff --git a/lib/plugins/vuid_manager/index.react_native.ts b/lib/plugins/vuid_manager/index.react_native.ts new file mode 100644 index 000000000..5b0d68bb9 --- /dev/null +++ b/lib/plugins/vuid_manager/index.react_native.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2024, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { VuidManager } from "."; +import ReactNativeAsyncStorageCache from "../key_value_cache/reactNativeAsyncStorageCache"; + +export const vuidManager = new VuidManager(new ReactNativeAsyncStorageCache()); diff --git a/lib/plugins/vuid_manager/index.ts b/lib/plugins/vuid_manager/index.ts index 8587724d6..5bb366581 100644 --- a/lib/plugins/vuid_manager/index.ts +++ b/lib/plugins/vuid_manager/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022-2023, Optimizely + * Copyright 2022-2024, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,43 @@ * limitations under the License. */ +import { LogHandler } from '../../modules/logging'; import { uuid } from '../../utils/fns'; import PersistentKeyValueCache from '../key_value_cache/persistentKeyValueCache'; +export type VuidManagerOptions = { + enableVuid: boolean; +} + export interface IVuidManager { - readonly vuid: string; + /** + * Current VUID value being used + * @returns Current VUID stored in the VuidManager + */ + readonly vuid: string | undefined; + /** + * Indicates whether the VUID use is enabled + * @returns *true* if the VUID use is enabled otherwise *false* + */ + readonly vuidEnabled: boolean; + /** + * Initialize the VuidManager + * @returns Promise that resolves when the VuidManager is initialized + */ + configure(options: VuidManagerOptions): Promise; + setLogger(logger: LogHandler): void; } /** * Manager for creating, persisting, and retrieving a Visitor Unique Identifier */ export class VuidManager implements IVuidManager { + /** + * Handler for recording execution logs + * @private + */ + private logger?: LogHandler; + /** * Prefix used as part of the VUID format * @public @@ -43,40 +69,68 @@ export class VuidManager implements IVuidManager { * Current VUID value being used * @private */ - private _vuid: string; + private _vuid: string | undefined; /** * Get the current VUID value being used */ - get vuid(): string { + get vuid(): string | undefined { return this._vuid; } - private constructor() { - this._vuid = ''; + /** + * Current state of the VUID use + * @private + */ + private _vuidEnabled = false; + + /** + * Indicates whether the VUID use is enabled + */ + get vuidEnabled(): boolean { + return this._vuidEnabled; } /** - * Instance of the VUID Manager + * The cache used to store the VUID * @private + * @readonly */ - private static _instance: VuidManager; + private readonly cache: PersistentKeyValueCache; + + private waitPromise: Promise = Promise.resolve(); + + constructor(cache: PersistentKeyValueCache, logger?: LogHandler) { + this.cache = cache; + this.logger = logger; + } + + setLogger(logger: LogHandler): void { + this.logger = logger; + } /** - * Gets the current instance of the VUID Manager, initializing if needed - * @param cache Caching mechanism to use for persisting the VUID outside working memory * - * @returns An instance of VuidManager + * Configures the VuidManager + * @returns Promise that resolves when the VuidManager is configured */ - static async instance(cache: PersistentKeyValueCache): Promise { - if (!this._instance) { - this._instance = new VuidManager(); + async configure(options: VuidManagerOptions): Promise { + const configureFn = async () => { + this._vuidEnabled = options.enableVuid; + + if (!this.vuidEnabled) { + await this.cache.remove(this._keyForVuid); + this._vuid = undefined; + return; + } + + if (!this._vuid) { + await this.load(this.cache); + } } - if (!this._instance._vuid) { - await this._instance.load(cache); - } - - return this._instance; + this.waitPromise = this.waitPromise.then(configureFn, configureFn); + this.waitPromise.catch(() => {}); + return this.waitPromise; } /** @@ -128,14 +182,5 @@ export class VuidManager implements IVuidManager { * @param vuid VistorId to check * @returns *true* if the VisitorId is valid otherwise *false* for invalid */ - static isVuid = (vuid: string): boolean => vuid?.startsWith(VuidManager.vuid_prefix) || false; - - /** - * Function used in unit testing to reset the VuidManager - * **Important**: This should not to be used in production code - * @private - */ - private static _reset(): void { - this._instance._vuid = ''; - } + static isVuid = (vuid: string | undefined): boolean => vuid?.startsWith(VuidManager.vuid_prefix) || false; } diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 495051866..9af9ee96c 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -38,6 +38,7 @@ import { IOdpManager } from './core/odp/odp_manager'; import { IUserAgentParser } from './core/odp/user_agent_parser'; import PersistentCache from './plugins/key_value_cache/persistentKeyValueCache'; import { ProjectConfig } from './core/project_config'; +import { IVuidManager } from './plugins/vuid_manager'; export interface BucketerParams { experimentId: string; @@ -111,6 +112,10 @@ export interface OdpOptions { userAgentParser?: IUserAgentParser; } +export type VuidOptions = { + enableVuid: boolean; +} + export interface ListenerPayload { userId: string; attributes?: UserAttributes; @@ -300,6 +305,8 @@ export interface OptimizelyOptions { userProfileService?: UserProfileService | null; defaultDecideOptions?: OptimizelyDecideOption[]; odpManager?: IOdpManager; + vuidOptions?: VuidOptions, + vuidManager?: IVuidManager; notificationCenter: NotificationCenterImpl; } @@ -405,6 +412,7 @@ export interface Config extends ConfigLite { eventMaxQueueSize?: number; // Maximum size for the event queue sdkKey?: string; odpOptions?: OdpOptions; + vuidOptions?: VuidOptions; persistentCacheProvider?: PersistentCacheProvider; } diff --git a/lib/utils/enums/index.ts b/lib/utils/enums/index.ts index 962d06c30..07ee936e9 100644 --- a/lib/utils/enums/index.ts +++ b/lib/utils/enums/index.ts @@ -128,7 +128,8 @@ export const LOG_MESSAGES = { RETURNING_STORED_VARIATION: '%s: Returning previously activated variation "%s" of experiment "%s" for user "%s" from user profile.', ROLLOUT_HAS_NO_EXPERIMENTS: '%s: Rollout of feature %s has no experiments', - SAVED_VARIATION: '%s: Saved variation "%s" of experiment "%s" for user "%s".', + SAVED_USER_VARIATION: '%s: Saved user profile for user "%s".', + UPDATED_USER_VARIATION: '%s: Updated variation "%s" of experiment "%s" for user "%s".', SAVED_VARIATION_NOT_FOUND: '%s: User %s was previously bucketed into variation with ID %s for experiment %s, but no matching variation was found.', SHOULD_NOT_DISPATCH_ACTIVATE: '%s: Experiment %s is not in "Running" state. Not activating user.', @@ -221,7 +222,7 @@ export const NODE_CLIENT_ENGINE = 'node-sdk'; export const REACT_CLIENT_ENGINE = 'react-sdk'; export const REACT_NATIVE_CLIENT_ENGINE = 'react-native-sdk'; export const REACT_NATIVE_JS_CLIENT_ENGINE = 'react-native-js-sdk'; -export const CLIENT_VERSION ='5.3.4' +export const CLIENT_VERSION ='6.0.0' export const DECISION_NOTIFICATION_TYPES = { AB_TEST: 'ab-test', diff --git a/lib/utils/lru_cache/lru_cache.tests.ts b/lib/utils/lru_cache/lru_cache.tests.ts index 4c9de8d1a..a6cb3f86d 100644 --- a/lib/utils/lru_cache/lru_cache.tests.ts +++ b/lib/utils/lru_cache/lru_cache.tests.ts @@ -1,5 +1,5 @@ /** - * Copyright 2022, Optimizely + * Copyright 2022, 2024 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -242,28 +242,28 @@ describe('/lib/core/odp/lru_cache (Default)', () => { await sleep(150); assert.equal(cache.map.size, 0); + }); - it('should be fully functional after resetting the cache', () => { - cache.save({ key: 'c', value: 300 }); // { c: 300 } - cache.save({ key: 'd', value: 400 }); // { c: 300, d: 400 } - assert.isNull(cache.peek('b')); - assert.equal(cache.peek('c'), 300); - assert.equal(cache.peek('d'), 400); - - cache.save({ key: 'a', value: 500 }); // { d: 400, a: 500 } - cache.save({ key: 'b', value: 600 }); // { a: 500, b: 600 } - assert.isNull(cache.peek('c')); - assert.equal(cache.peek('a'), 500); - assert.equal(cache.peek('b'), 600); - - const _ = cache.lookup('a'); // { b: 600, a: 500 } - assert.equal(500, _); - - cache.save({ key: 'c', value: 700 }); // { a: 500, c: 700 } - assert.isNull(cache.peek('b')); - assert.equal(cache.peek('a'), 500); - assert.equal(cache.peek('c'), 700); - }); + it('should be fully functional after resetting the cache', () => { + cache.save({ key: 'c', value: 300 }); // { c: 300 } + cache.save({ key: 'd', value: 400 }); // { c: 300, d: 400 } + assert.isNull(cache.peek('b')); + assert.equal(cache.peek('c'), 300); + assert.equal(cache.peek('d'), 400); + + cache.save({ key: 'a', value: 500 }); // { d: 400, a: 500 } + cache.save({ key: 'b', value: 600 }); // { a: 500, b: 600 } + assert.isNull(cache.peek('c')); + assert.equal(cache.peek('a'), 500); + assert.equal(cache.peek('b'), 600); + + const _ = cache.lookup('a'); // { b: 600, a: 500 } + assert.equal(500, _); + + cache.save({ key: 'c', value: 700 }); // { a: 500, c: 700 } + assert.isNull(cache.peek('b')); + assert.equal(cache.peek('a'), 500); + assert.equal(cache.peek('c'), 700); }); }); }); diff --git a/package-lock.json b/package-lock.json index 28b366dc1..387235e57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@optimizely/optimizely-sdk", - "version": "5.3.4", + "version": "6.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@optimizely/optimizely-sdk", - "version": "5.3.4", + "version": "6.0.0", "license": "Apache-2.0", "dependencies": { "decompress-response": "^4.2.1", @@ -65,10 +65,9 @@ "webpack": "^5.74.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "peerDependencies": { - "@babel/runtime": "^7.0.0", "@react-native-async-storage/async-storage": "^1.2.0", "@react-native-community/netinfo": "^11.3.2", "fast-text-encoding": "^1.0.6", @@ -2541,6 +2540,7 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "dev": true, "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" @@ -12946,6 +12946,7 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, "peer": true }, "node_modules/regenerator-transform": { @@ -16896,6 +16897,7 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "dev": true, "peer": true, "requires": { "regenerator-runtime": "^0.14.0" @@ -25039,6 +25041,7 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, "peer": true }, "regenerator-transform": { diff --git a/package.json b/package.json index 75d8fe42e..4fc71a773 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@optimizely/optimizely-sdk", - "version": "5.3.4", + "version": "6.0.0", "description": "JavaScript SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts", "module": "dist/optimizely.browser.es.js", "main": "dist/optimizely.node.min.js", @@ -94,7 +94,7 @@ }, "license": "Apache-2.0", "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "keywords": [ "optimizely" @@ -160,7 +160,6 @@ "webpack": "^5.74.0" }, "peerDependencies": { - "@babel/runtime": "^7.0.0", "@react-native-async-storage/async-storage": "^1.2.0", "@react-native-community/netinfo": "^11.3.2", "react-native-get-random-values": "^1.11.0", diff --git a/tests/index.react_native.spec.ts b/tests/index.react_native.spec.ts index a11f40c32..873a1fffb 100644 --- a/tests/index.react_native.spec.ts +++ b/tests/index.react_native.spec.ts @@ -93,7 +93,7 @@ describe('javascript-sdk/react-native', () => { expect(optlyInstance).toBeInstanceOf(Optimizely); // @ts-ignore - expect(optlyInstance.clientVersion).toEqual('5.3.4'); + expect(optlyInstance.clientVersion).toEqual('6.0.0'); }); it('should set the React Native JS client engine and javascript SDK version', () => { diff --git a/tests/nodeRequestHandler.spec.ts b/tests/nodeRequestHandler.spec.ts index 149cc7270..ff91ab6fb 100644 --- a/tests/nodeRequestHandler.spec.ts +++ b/tests/nodeRequestHandler.spec.ts @@ -202,7 +202,7 @@ describe('NodeRequestHandler', () => { jest.clearAllTimers(); }); - it.only('should reject the response promise and abort the request when the response is not received before the timeout', async () => { + it('should reject the response promise and abort the request when the response is not received before the timeout', async () => { const scope = nock(host) .get(path) .delay({ head: 2000, body: 2000 }) diff --git a/tests/odpEventManager.spec.ts b/tests/odpEventManager.spec.ts index 31bc7a753..395d5650f 100644 --- a/tests/odpEventManager.spec.ts +++ b/tests/odpEventManager.spec.ts @@ -634,7 +634,7 @@ describe('OdpEventManager', () => { const fsUserId = 'test-fs-user-id'; eventManager.start(); - eventManager.registerVuid(vuid); + eventManager.sendInitializedEvent(vuid); jest.advanceTimersByTime(250); diff --git a/tests/odpManager.browser.spec.ts b/tests/odpManager.browser.spec.ts index b9ecb76f0..394158bbb 100644 --- a/tests/odpManager.browser.spec.ts +++ b/tests/odpManager.browser.spec.ts @@ -14,29 +14,20 @@ * limitations under the License. */ -import { anything, capture, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { instance, mock, resetCalls } from 'ts-mockito'; -import { LOG_MESSAGES, ODP_DEFAULT_EVENT_TYPE, ODP_EVENT_ACTION } from './../lib/utils/enums/index'; -import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; - -import { LogHandler, LogLevel } from '../lib/modules/logging'; +import { LogHandler } from '../lib/modules/logging'; import { RequestHandler } from '../lib/utils/http_request_handler/http'; import { BrowserLRUCache } from './../lib/utils/lru_cache/browser_lru_cache'; import { BrowserOdpManager } from './../lib/plugins/odp_manager/index.browser'; -import { IOdpEventManager, OdpOptions } from './../lib/shared_types'; +import { OdpOptions } from './../lib/shared_types'; import { OdpConfig } from '../lib/core/odp/odp_config'; import { BrowserOdpEventApiManager } from '../lib/plugins/odp/event_api_manager/index.browser'; import { OdpSegmentManager } from './../lib/core/odp/odp_segment_manager'; import { OdpSegmentApiManager } from '../lib/core/odp/odp_segment_api_manager'; -import { VuidManager } from '../lib/plugins/vuid_manager'; import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser_request_handler'; -import { IUserAgentParser } from '../lib/core/odp/user_agent_parser'; -import { UserAgentInfo } from '../lib/core/odp/user_agent_info'; -import { OdpEvent } from '../lib/core/odp/odp_event'; -import { LRUCache } from '../lib/utils/lru_cache'; import { BrowserOdpEventManager } from '../lib/plugins/odp/event_manager/index.browser'; -import { OdpManager } from '../lib/core/odp/odp_manager'; const keyA = 'key-a'; const hostA = 'host-a'; @@ -107,20 +98,6 @@ describe('OdpManager', () => { resetCalls(mockSegmentManager); }); - const browserOdpManagerInstance = () => - BrowserOdpManager.createInstance({ - odpOptions: { - eventManager: fakeEventManager, - segmentManager: fakeSegmentManager, - }, - }); - - it('should create VUID automatically on BrowserOdpManager initialization', async () => { - const browserOdpManager = browserOdpManagerInstance(); - const vuidManager = await VuidManager.instance(BrowserOdpManager.cache); - expect(browserOdpManager.vuid).toBe(vuidManager.vuid); - }); - describe('Populates BrowserOdpManager correctly with all odpOptions', () => { beforeAll(() => { diff --git a/tests/odpManager.spec.ts b/tests/odpManager.spec.ts index 90228cc52..67c1f0ddc 100644 --- a/tests/odpManager.spec.ts +++ b/tests/odpManager.spec.ts @@ -17,13 +17,10 @@ import { anything, capture, instance, mock, resetCalls, verify, when } from 'ts-mockito'; -import { LOG_MESSAGES } from './../lib/utils/enums/index'; import { ERROR_MESSAGES, ODP_USER_KEY } from './../lib/utils/enums/index'; import { LogHandler, LogLevel } from '../lib/modules/logging'; import { RequestHandler } from '../lib/utils/http_request_handler/http'; -import { BrowserLRUCache } from './../lib/utils/lru_cache/browser_lru_cache'; - import { OdpManager, Status } from '../lib/core/odp/odp_manager'; import { OdpConfig, OdpIntegratedConfig, OdpIntegrationConfig, OdpNotIntegratedConfig } from '../lib/core/odp/odp_config'; import { NodeOdpEventApiManager as OdpEventApiManager } from '../lib/plugins/odp/event_api_manager/index.node'; @@ -33,7 +30,6 @@ import { OdpSegmentApiManager } from '../lib/core/odp/odp_segment_api_manager'; import { IOdpEventManager } from '../lib/shared_types'; import { wait } from './testUtils'; import { resolvablePromise } from '../lib/utils/promise/resolvablePromise'; -import exp from 'constants'; const keyA = 'key-a'; const hostA = 'host-a'; @@ -125,7 +121,7 @@ describe('OdpManager', () => { resetCalls(mockSegmentManager); }); - + it('should be in stopped status and not ready if constructed without odpIntegrationConfig', () => { const odpManager = testOdpManager({ segmentManager, @@ -137,19 +133,20 @@ describe('OdpManager', () => { expect(odpManager.getStatus()).toEqual(Status.Stopped); }); - it('should call initialzeVuid on construction if vuid is enabled', () => { - const vuidInitializer = jest.fn(); + // TODO: this test should move to optimizely class + // it('should call initialzeVuid on construction if vuid is enabled', () => { + // const vuidInitializer = jest.fn(); - const odpManager = testOdpManager({ - segmentManager, - eventManager, - logger, - vuidEnabled: true, - vuidInitializer: vuidInitializer, - }); + // testOdpManager({ + // segmentManager, + // eventManager, + // logger, + // vuidEnabled: true, + // vuidInitializer: vuidInitializer, + // }); - expect(vuidInitializer).toHaveBeenCalledTimes(1); - }); + // expect(vuidInitializer).toHaveBeenCalledTimes(1); + // }); it('should become ready only after odpIntegrationConfig is provided if vuid is not enabled', async () => { const odpManager = testOdpManager({ @@ -170,59 +167,35 @@ describe('OdpManager', () => { expect(odpManager.isReady()).toBe(true); }); - it('should become ready if odpIntegrationConfig is provided in constructor and then initialzeVuid', async () => { - const vuidPromise = resolvablePromise(); + it('should become ready if odpIntegrationConfig is provided in constructor', async () => { const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; - const vuidInitializer = () => { - return vuidPromise.promise; - } - const odpManager = testOdpManager({ odpIntegrationConfig, segmentManager, eventManager, logger, vuidEnabled: true, - vuidInitializer, }); - await wait(500); - expect(odpManager.isReady()).toBe(false); - - vuidPromise.resolve(); - - await odpManager.onReady(); - expect(odpManager.isReady()).toBe(true); + await expect(odpManager.onReady()).resolves.not.toThrow(); }); - it('should become ready after odpIntegrationConfig is provided using updateSettings() and then initialzeVuid finishes', async () => { - const vuidPromise = resolvablePromise(); - - const vuidInitializer = () => { - return vuidPromise.promise; - } - + it('should become ready after odpIntegrationConfig is provided using updateSettings()', async () => { const odpManager = testOdpManager({ segmentManager, eventManager, logger, vuidEnabled: true, - vuidInitializer, }); - + await wait(500); expect(odpManager.isReady()).toBe(false); const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; odpManager.updateSettings(odpIntegrationConfig); - - await wait(500); - expect(odpManager.isReady()).toBe(false); - vuidPromise.resolve(); - - await odpManager.onReady(); + await expect(odpManager.onReady()).resolves.not.toThrow(); expect(odpManager.isReady()).toBe(true); }); @@ -249,7 +222,7 @@ describe('OdpManager', () => { const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; odpManager.updateSettings(odpIntegrationConfig); - + await odpManager.onReady(); expect(odpManager.isReady()).toBe(true); }); @@ -266,7 +239,7 @@ describe('OdpManager', () => { const odpIntegrationConfig: OdpNotIntegratedConfig = { integrated: false }; odpManager.updateSettings(odpIntegrationConfig); - + await odpManager.onReady(); expect(odpManager.isReady()).toBe(true); expect(odpManager.getStatus()).toEqual(Status.Stopped); @@ -289,7 +262,7 @@ describe('OdpManager', () => { logger, vuidEnabled: true, }); - + verify(mockEventManager.updateSettings(anything())).once(); const [eventOdpConfig] = capture(mockEventManager.updateSettings).first(); expect(eventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); @@ -316,7 +289,7 @@ describe('OdpManager', () => { }); odpManager.updateSettings(odpIntegrationConfig); - + verify(mockEventManager.updateSettings(anything())).once(); const [eventOdpConfig] = capture(mockEventManager.updateSettings).first(); expect(eventOdpConfig.equals(odpIntegrationConfig.odpConfig)).toBe(true); @@ -415,24 +388,26 @@ describe('OdpManager', () => { verify(mockEventManager.stop()).once(); }); - it('should register vuid after becoming ready if odp is integrated', async () => { - const odpIntegrationConfig: OdpIntegratedConfig = { - integrated: true, - odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) - }; - const odpManager = testOdpManager({ - odpIntegrationConfig, - segmentManager, - eventManager, - logger, - vuidEnabled: true, - }); + // TODO: registering vuid tests should move to optimizely class + // it('should register vuid after becoming ready if odp is integrated', async () => { + // const odpIntegrationConfig: OdpIntegratedConfig = { + // integrated: true, + // odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + // }; - await odpManager.onReady(); - - verify(mockEventManager.registerVuid(anything())).once(); - }); + // const odpManager = testOdpManager({ + // odpIntegrationConfig, + // segmentManager, + // eventManager, + // logger, + // vuidEnabled: true, + // }); + + // await odpManager.onReady(); + + // verify(mockEventManager.registerVuid(anything())).once(); + // }); it('should call eventManager.identifyUser with correct parameters when identifyUser is called', async () => { const odpIntegrationConfig: OdpIntegratedConfig = { @@ -515,6 +490,40 @@ describe('OdpManager', () => { expect(event2.identifiers).toEqual(identifiers); }); + it('should add the available vuid to sendEvent identifies', async () => { + const odpIntegrationConfig: OdpIntegratedConfig = { + integrated: true, + odpConfig: new OdpConfig(keyA, hostA, pixelA, segmentsA) + }; + + const odpManager = testOdpManager({ + odpIntegrationConfig, + segmentManager, + eventManager, + logger, + vuidEnabled: true, + }); + + await odpManager.onReady(); + odpManager.setVuid('vuid_test'); + + const identifiers = new Map([['email', 'a@b.com']]); + const data = new Map([['key1', 'value1'], ['key2', 'value2']]); + + odpManager.sendEvent({ + action: 'action', + type: 'type', + identifiers, + data, + }); + + const [event] = capture(mockEventManager.sendEvent).byCallIndex(0); + expect(event.action).toEqual('action'); + expect(event.type).toEqual('type'); + expect(event.identifiers.get(ODP_USER_KEY.VUID)).toEqual('vuid_test'); + expect(event.data).toEqual(data); + }); + it('should throw an error if event action is empty string and not call eventManager', async () => { const odpIntegrationConfig: OdpIntegratedConfig = { @@ -576,7 +585,7 @@ describe('OdpManager', () => { verify(mockEventManager.sendEvent(anything())).never(); }); - it.only('should fetch qualified segments correctly for both fs_user_id and vuid', async () => { + it('should fetch qualified segments correctly for both fs_user_id and vuid', async () => { const userId = 'user123'; const vuid = 'vuid_123'; @@ -681,7 +690,7 @@ describe('OdpManager', () => { expect(segments).toBeNull(); odpManager.identifyUser('vuid_user1'); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).twice(); + verify(mockLogger.log(LogLevel.INFO, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).once(); verify(mockEventManager.identifyUser(anything(), anything())).never(); const identifiers = new Map([['email', 'a@b.com']]); @@ -694,7 +703,7 @@ describe('OdpManager', () => { data, }); - verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).thrice(); + verify(mockLogger.log(LogLevel.ERROR, ERROR_MESSAGES.ODP_NOT_INTEGRATED)).twice(); verify(mockEventManager.sendEvent(anything())).never(); }); }); diff --git a/tests/vuidManager.spec.ts b/tests/vuidManager.spec.ts index 87ee8f666..2bc246a7c 100644 --- a/tests/vuidManager.spec.ts +++ b/tests/vuidManager.spec.ts @@ -19,9 +19,12 @@ import { VuidManager } from '../lib/plugins/vuid_manager'; import PersistentKeyValueCache from '../lib/plugins/key_value_cache/persistentKeyValueCache'; import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { LogHandler } from '../lib/modules/logging/models'; +import { resolvablePromise } from '../lib/utils/promise/resolvablePromise'; describe('VuidManager', () => { let mockCache: PersistentKeyValueCache; + let mockLogger: LogHandler; beforeAll(() => { mockCache = mock(); @@ -29,16 +32,17 @@ describe('VuidManager', () => { when(mockCache.get(anyString())).thenResolve(''); when(mockCache.remove(anyString())).thenResolve(true); when(mockCache.set(anyString(), anything())).thenResolve(); - VuidManager.instance(instance(mockCache)); + + mockLogger = mock(); }); beforeEach(() => { resetCalls(mockCache); - VuidManager['_reset'](); + resetCalls(mockLogger); }); it('should make a VUID', async () => { - const manager = await VuidManager.instance(instance(mockCache)); + const manager = new VuidManager(instance(mockCache)); const vuid = manager['makeVuid'](); @@ -48,55 +52,73 @@ describe('VuidManager', () => { }); it('should test if a VUID is valid', async () => { - const manager = await VuidManager.instance(instance(mockCache)); - expect(VuidManager.isVuid('vuid_123')).toBe(true); expect(VuidManager.isVuid('vuid-123')).toBe(false); expect(VuidManager.isVuid('123')).toBe(false); }); - it('should auto-save and auto-load', async () => { - const cache = instance(mockCache); - - await cache.remove('optimizely-odp'); - - const manager1 = await VuidManager.instance(cache); - const vuid1 = manager1.vuid; - - const manager2 = await VuidManager.instance(cache); - const vuid2 = manager2.vuid; - - expect(vuid1).toStrictEqual(vuid2); - expect(VuidManager.isVuid(vuid1)).toBe(true); - expect(VuidManager.isVuid(vuid2)).toBe(true); - - await cache.remove('optimizely-odp'); + describe('when configure with enableVuid = true', () => { + it('should handle no valid optimizely-vuid in the cache', async () => { + when(mockCache.get(anyString())).thenResolve(undefined); + const manager = new VuidManager(instance(mockCache)) + await manager.configure({ enableVuid: true }); + + verify(mockCache.get(anyString())).once(); + verify(mockCache.set(anyString(), anything())).once(); + expect(VuidManager.isVuid(manager.vuid)).toBe(true); + }); + + it('should create a new vuid if old VUID from cache is not valid', async () => { + when(mockCache.get(anyString())).thenResolve('vuid-not-valid'); + const manager = new VuidManager(instance(mockCache)); + await manager.configure({ enableVuid: true }); + + verify(mockCache.get(anyString())).once(); + verify(mockCache.set(anyString(), anything())).once(); + expect(VuidManager.isVuid(manager.vuid)).toBe(true); + }); + + it('should never call remove when enableVuid is true', async () => { + const manager = new VuidManager(instance(mockCache)); + await manager.configure({ enableVuid: true }); + + verify(mockCache.remove(anyString())).never(); + expect(VuidManager.isVuid(manager.vuid)).toBe(true); + }); + }); - // should end up being a new instance since we just removed it above - await manager2['load'](cache); - const vuid3 = manager2.vuid; + it('should call remove when vuid is disabled', async () => { + const manager = new VuidManager(instance(mockCache)); + await manager.configure({ enableVuid: false }); - expect(vuid3).not.toStrictEqual(vuid1); - expect(VuidManager.isVuid(vuid3)).toBe(true); + verify(mockCache.remove(anyString())).once(); + expect(manager.vuid).toBeUndefined(); }); - it('should handle no valid optimizely-vuid in the cache', async () => { - when(mockCache.get(anyString())).thenResolve(undefined); + it('should sequence configure calls', async() => { + const mockCache = mock(); + when(mockCache.contains(anyString())).thenResolve(true); + when(mockCache.get(anyString())).thenResolve(''); - const manager = await VuidManager.instance(instance(mockCache)); // load() called initially + const removePromise = resolvablePromise(); + when(mockCache.remove(anyString())).thenReturn(removePromise.promise); + when(mockCache.set(anyString(), anything())).thenResolve(); - verify(mockCache.get(anyString())).once(); - verify(mockCache.set(anyString(), anything())).once(); - expect(VuidManager.isVuid(manager.vuid)).toBe(true); - }); + const manager = new VuidManager(instance(mockCache)); + + // this should try to remove vuid, which should stay pending + manager.configure({ enableVuid: false }); - it('should create a new vuid if old VUID from cache is not valid', async () => { - when(mockCache.get(anyString())).thenResolve('vuid-not-valid'); + // this should try to get the vuid from store + manager.configure({ enableVuid: true }); + verify(mockCache.get(anyString())).never(); - const manager = await VuidManager.instance(instance(mockCache)); + removePromise.resolve(true); + //ensure micro task queue is exhaused + for(let i = 0; i < 100; i++) { + await Promise.resolve(); + } - verify(mockCache.get(anyString())).once(); - verify(mockCache.set(anyString(), anything())).once(); - expect(VuidManager.isVuid(manager.vuid)).toBe(true); + verify(mockCache.get(anyString())).once() }); });