diff --git a/lib/client_factory.ts b/lib/client_factory.ts index 187334cc4..42c650fd6 100644 --- a/lib/client_factory.ts +++ b/lib/client_factory.ts @@ -48,6 +48,7 @@ export const getOptimizelyInstance = (config: OptimizelyFactoryConfig): Client | clientVersion, jsonSchemaValidator, userProfileService, + userProfileServiceAsync, defaultDecideOptions, disposable, requestHandler, @@ -75,6 +76,7 @@ export const getOptimizelyInstance = (config: OptimizelyFactoryConfig): Client | clientVersion: clientVersion || CLIENT_VERSION, jsonSchemaValidator, userProfileService, + userProfileServiceAsync, defaultDecideOptions, disposable, logger, diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts index f3459ef0e..e2a186eca 100644 --- a/lib/core/decision_service/index.spec.ts +++ b/lib/core/decision_service/index.spec.ts @@ -67,11 +67,13 @@ type MockCmabService = { type DecisionServiceInstanceOpt = { logger?: boolean; userProfileService?: boolean; + userProfileServiceAsync?: boolean; } type DecisionServiceInstance = { logger?: MockLogger; userProfileService?: MockUserProfileService; + userProfileServiceAsync?: MockUserProfileService; cmabService: MockCmabService; decisionService: DecisionService; } @@ -83,6 +85,11 @@ const getDecisionService = (opt: DecisionServiceInstanceOpt = {}): DecisionServi save: vi.fn(), } : undefined; + const userProfileServiceAsync = opt.userProfileServiceAsync ? { + lookup: vi.fn(), + save: vi.fn(), + } : undefined; + const cmabService = { getDecision: vi.fn(), }; @@ -90,6 +97,7 @@ const getDecisionService = (opt: DecisionServiceInstanceOpt = {}): DecisionServi const decisionService = new DecisionService({ logger, userProfileService, + userProfileServiceAsync, UNSTABLE_conditionEvaluators: {}, cmabService, }); @@ -97,6 +105,7 @@ const getDecisionService = (opt: DecisionServiceInstanceOpt = {}): DecisionServi return { logger, userProfileService, + userProfileServiceAsync, decisionService, cmabService, }; @@ -1442,6 +1451,270 @@ describe('DecisionService', () => { {}, ); }); + + it('should use userProfileServiceAsync if available and sync user profile service is unavialable', async () => { + const { decisionService, cmabService, userProfileServiceAsync } = getDecisionService({ + userProfileService: false, + userProfileServiceAsync: true, + }); + + userProfileServiceAsync?.lookup.mockImplementation((userId: string) => { + if (userId === 'tester-1') { + return Promise.resolve({ + user_id: 'tester-1', + experiment_bucket_map: { + '2003': { + variation_id: '5001', + }, + }, + }); + } + return Promise.resolve(null); + }); + + userProfileServiceAsync?.save.mockImplementation(() => Promise.resolve()); + + cmabService.getDecision.mockResolvedValue({ + variationId: '5003', + cmabUuid: 'uuid-test', + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user1 = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester-1', + attributes: { + country: 'BD', + age: 80, // should satisfy audience condition for exp_3 which is cmab and not others + }, + }); + + const user2 = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester-2', + attributes: { + country: 'BD', + age: 80, // should satisfy audience condition for exp_3 which is cmab and not others + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user1, {}).get(); + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_3'], + variation: config.variationIdMap['5001'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(cmabService.getDecision).not.toHaveBeenCalled(); + expect(userProfileServiceAsync?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileServiceAsync?.lookup).toHaveBeenCalledWith('tester-1'); + + const value2 = decisionService.resolveVariationsForFeatureList('async', config, [feature], user2, {}).get(); + expect(value2).toBeInstanceOf(Promise); + + const variation2 = (await value2)[0]; + expect(variation2.result).toEqual({ + cmabUuid: 'uuid-test', + experiment: config.experimentKeyMap['exp_3'], + variation: config.variationIdMap['5003'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileServiceAsync?.lookup).toHaveBeenCalledTimes(2); + expect(userProfileServiceAsync?.lookup).toHaveBeenNthCalledWith(2, 'tester-2'); + expect(userProfileServiceAsync?.save).toHaveBeenCalledTimes(1); + expect(userProfileServiceAsync?.save).toHaveBeenCalledWith({ + user_id: 'tester-2', + experiment_bucket_map: { + '2003': { + variation_id: '5003', + }, + }, + }); + }); + + it('should log error and perform normal decision fetch if async userProfile lookup fails', async () => { + const { decisionService, cmabService, userProfileServiceAsync, logger } = getDecisionService({ + userProfileService: false, + userProfileServiceAsync: true, + logger: true, + }); + + userProfileServiceAsync?.lookup.mockImplementation((userId: string) => { + return Promise.reject(new Error('I am an error')); + }); + + userProfileServiceAsync?.save.mockImplementation(() => Promise.resolve()); + + cmabService.getDecision.mockResolvedValue({ + variationId: '5003', + cmabUuid: 'uuid-test', + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + country: 'BD', + age: 80, // should satisfy audience condition for exp_3 which is cmab and not others + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + cmabUuid: 'uuid-test', + experiment: config.experimentKeyMap['exp_3'], + variation: config.variationIdMap['5003'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileServiceAsync?.lookup).toHaveBeenCalledWith('tester'); + expect(cmabService.getDecision).toHaveBeenCalledTimes(1); + expect(cmabService.getDecision).toHaveBeenCalledWith( + config, + user, + '2003', // id of exp_3 + {}, + ); + + expect(logger?.error).toHaveBeenCalledTimes(1); + expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_LOOKUP_ERROR, 'tester', 'I am an error'); + }); + + it('should log error async userProfile save fails', async () => { + const { decisionService, cmabService, userProfileServiceAsync, logger } = getDecisionService({ + userProfileService: false, + userProfileServiceAsync: true, + logger: true, + }); + + userProfileServiceAsync?.lookup.mockResolvedValue(null); + + userProfileServiceAsync?.save.mockRejectedValue(new Error('I am an error')); + + + cmabService.getDecision.mockResolvedValue({ + variationId: '5003', + cmabUuid: 'uuid-test', + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + country: 'BD', + age: 80, // should satisfy audience condition for exp_3 which is cmab and not others + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + cmabUuid: 'uuid-test', + experiment: config.experimentKeyMap['exp_3'], + variation: config.variationIdMap['5003'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileServiceAsync?.lookup).toHaveBeenCalledWith('tester'); + expect(cmabService.getDecision).toHaveBeenCalledTimes(1); + expect(cmabService.getDecision).toHaveBeenCalledWith( + config, + user, + '2003', // id of exp_3 + {}, + ); + + expect(userProfileServiceAsync?.save).toHaveBeenCalledTimes(1); + expect(userProfileServiceAsync?.save).toHaveBeenCalledWith({ + user_id: 'tester', + experiment_bucket_map: { + '2003': { + variation_id: '5003', + }, + }, + }); + expect(logger?.error).toHaveBeenCalledTimes(1); + expect(logger?.error).toHaveBeenCalledWith(USER_PROFILE_SAVE_ERROR, 'tester', 'I am an error'); + }); + + it('should use the sync user profile service if both sync and async ups are provided', async () => { + const { decisionService, userProfileService, userProfileServiceAsync, cmabService } = getDecisionService({ + userProfileService: true, + userProfileServiceAsync: true, + }); + + userProfileService?.lookup.mockReturnValue(null); + userProfileService?.save.mockReturnValue(null); + + userProfileServiceAsync?.lookup.mockResolvedValue(null); + userProfileServiceAsync?.save.mockResolvedValue(null); + + + cmabService.getDecision.mockResolvedValue({ + variationId: '5003', + cmabUuid: 'uuid-test', + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + country: 'BD', + age: 80, // should satisfy audience condition for exp_3 which is cmab and not others + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get(); + expect(value).toBeInstanceOf(Promise); + + const variation = (await value)[0]; + + expect(variation.result).toEqual({ + cmabUuid: 'uuid-test', + experiment: config.experimentKeyMap['exp_3'], + variation: config.variationIdMap['5003'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('tester'); + + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'tester', + experiment_bucket_map: { + '2003': { + variation_id: '5003', + }, + }, + }); + + expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled(); + expect(userProfileServiceAsync?.save).not.toHaveBeenCalled(); + }); }); describe('resolveVariationForFeatureList - sync', () => { @@ -1493,6 +1766,154 @@ describe('DecisionService', () => { expect(cmabService.getDecision).not.toHaveBeenCalled(); }); + + it('should ignore async user profile service', async () => { + const { decisionService, userProfileServiceAsync } = getDecisionService({ + userProfileService: false, + userProfileServiceAsync: true, + }); + + userProfileServiceAsync?.lookup.mockResolvedValue({ + user_id: 'tester', + experiment_bucket_map: { + '2002': { + variation_id: '5001', + }, + }, + }); + userProfileServiceAsync?.save.mockResolvedValue(null); + + mockBucket.mockImplementation((param: BucketerParams) => { + const ruleKey = param.experimentKey; + if (ruleKey === 'exp_2') { + return { + result: '5002', + reasons: [], + }; + } + return { + result: null, + reasons: [], + }; + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester', + attributes: { + age: 55, // should satisfy audience condition for exp_2 and exp_3 + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user, {}).get(); + + const variation = value[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled(); + expect(userProfileServiceAsync?.save).not.toHaveBeenCalled(); + }); + + it('should use sync user profile service', async () => { + const { decisionService, userProfileService, userProfileServiceAsync } = getDecisionService({ + userProfileService: true, + userProfileServiceAsync: true, + }); + + userProfileService?.lookup.mockImplementation((userId: string) => { + if (userId === 'tester-1') { + return { + user_id: 'tester-1', + experiment_bucket_map: { + '2002': { + variation_id: '5001', + }, + }, + }; + } + return null; + }); + + userProfileServiceAsync?.lookup.mockResolvedValue(null); + userProfileServiceAsync?.save.mockResolvedValue(null); + + mockBucket.mockImplementation((param: BucketerParams) => { + const ruleKey = param.experimentKey; + if (ruleKey === 'exp_2') { + return { + result: '5002', + reasons: [], + }; + } + return { + result: null, + reasons: [], + }; + }); + + const config = createProjectConfig(getDecisionTestDatafile()); + + const user1 = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester-1', + attributes: { + age: 55, // should satisfy audience condition for exp_2 and exp_3 + }, + }); + + const feature = config.featureKeyMap['flag_1']; + const value = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user1, {}).get(); + + const variation = value[0]; + + expect(variation.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5001'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(1); + expect(userProfileService?.lookup).toHaveBeenCalledWith('tester-1'); + + const user2 = new OptimizelyUserContext({ + optimizely: {} as any, + userId: 'tester-2', + attributes: { + age: 55, // should satisfy audience condition for exp_2 and exp_3 + }, + }); + + const value2 = decisionService.resolveVariationsForFeatureList('sync', config, [feature], user2, {}).get(); + const variation2 = value2[0]; + expect(variation2.result).toEqual({ + experiment: config.experimentKeyMap['exp_2'], + variation: config.variationIdMap['5002'], + decisionSource: DECISION_SOURCES.FEATURE_TEST, + }); + + expect(userProfileService?.lookup).toHaveBeenCalledTimes(2); + expect(userProfileService?.lookup).toHaveBeenNthCalledWith(2, 'tester-2'); + expect(userProfileService?.save).toHaveBeenCalledTimes(1); + expect(userProfileService?.save).toHaveBeenCalledWith({ + user_id: 'tester-2', + experiment_bucket_map: { + '2002': { + variation_id: '5002', + }, + }, + }); + + expect(userProfileServiceAsync?.lookup).not.toHaveBeenCalled(); + expect(userProfileServiceAsync?.save).not.toHaveBeenCalled(); + }); }); describe('getVariationsForFeatureList', () => { diff --git a/lib/core/decision_service/index.ts b/lib/core/decision_service/index.ts index e8f29cf84..370ad356c 100644 --- a/lib/core/decision_service/index.ts +++ b/lib/core/decision_service/index.ts @@ -46,6 +46,7 @@ import { UserAttributes, UserProfile, UserProfileService, + UserProfileServiceAsync, Variation, } from '../../shared_types'; @@ -119,6 +120,7 @@ export interface DecisionObj { interface DecisionServiceOptions { userProfileService?: UserProfileService; + userProfileServiceAsync?: UserProfileServiceAsync; logger?: LoggerFacade; UNSTABLE_conditionEvaluators: unknown; cmabService: CmabService; @@ -165,6 +167,7 @@ export class DecisionService { private audienceEvaluator: AudienceEvaluator; private forcedVariationMap: { [key: string]: { [id: string]: string } }; private userProfileService?: UserProfileService; + private userProfileServiceAsync?: UserProfileServiceAsync; private cmabService: CmabService; constructor(options: DecisionServiceOptions) { @@ -172,6 +175,7 @@ export class DecisionService { this.audienceEvaluator = createAudienceEvaluator(options.UNSTABLE_conditionEvaluators, this.logger); this.forcedVariationMap = {}; this.userProfileService = options.userProfileService; + this.userProfileServiceAsync = options.userProfileServiceAsync; this.cmabService = options.cmabService; } @@ -638,6 +642,17 @@ export class DecisionService { return Value.of(op, emptyProfile); } + if (this.userProfileServiceAsync && op === 'async') { + return Value.of(op, this.userProfileServiceAsync.lookup(userId).catch((ex: any) => { + this.logger?.error( + USER_PROFILE_LOOKUP_ERROR, + userId, + ex.message, + ); + return emptyProfile; + })); + } + return Value.of(op, emptyProfile); } @@ -695,6 +710,15 @@ export class DecisionService { return Value.of(op, undefined); } + if (this.userProfileServiceAsync) { + return Value.of(op, this.userProfileServiceAsync.save({ + user_id: userId, + experiment_bucket_map: userProfile, + }).catch((ex: any) => { + this.logger?.error(USER_PROFILE_SAVE_ERROR, userId, ex.message); + })); + } + return Value.of(op, undefined); } diff --git a/lib/optimizely/index.tests.js b/lib/optimizely/index.tests.js index ac1a0fd96..21209e67d 100644 --- a/lib/optimizely/index.tests.js +++ b/lib/optimizely/index.tests.js @@ -239,6 +239,7 @@ describe('lib/optimizely', function() { sinon.assert.calledWith(decisionService.createDecisionService, { userProfileService: userProfileServiceInstance, + userProfileServiceAsync: undefined, logger: createdLogger, cmabService, UNSTABLE_conditionEvaluators: undefined, @@ -270,6 +271,7 @@ describe('lib/optimizely', function() { sinon.assert.calledWith(decisionService.createDecisionService, { userProfileService: undefined, + userProfileServiceAsync: undefined, logger: createdLogger, UNSTABLE_conditionEvaluators: undefined, cmabService, diff --git a/lib/optimizely/index.ts b/lib/optimizely/index.ts index 4b4e749a3..42e70ff48 100644 --- a/lib/optimizely/index.ts +++ b/lib/optimizely/index.ts @@ -36,6 +36,7 @@ import { FeatureVariableValue, OptimizelyDecision, Client, + UserProfileServiceAsync, } from '../shared_types'; import { newErrorDecision } from '../optimizely_decision'; import OptimizelyUserContext from '../optimizely_user_context'; @@ -130,6 +131,7 @@ export type OptimizelyOptions = { }; logger?: LoggerFacade; userProfileService?: UserProfileService | null; + userProfileServiceAsync?: UserProfileServiceAsync | null; defaultDecideOptions?: OptimizelyDecideOption[]; odpManager?: OdpManager; vuidManager?: VuidManager @@ -228,6 +230,7 @@ export default class Optimizely extends BaseService implements Client { this.decisionService = createDecisionService({ userProfileService: userProfileService, + userProfileServiceAsync: config.userProfileServiceAsync || undefined, cmabService: config.cmabService, logger: this.logger, UNSTABLE_conditionEvaluators: config.UNSTABLE_conditionEvaluators, diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 6f7bfc5e8..2ca797f27 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -97,6 +97,11 @@ export interface UserProfileService { save(profile: UserProfile): void; } +export interface UserProfileServiceAsync { + lookup(userId: string): Promise; + save(profile: UserProfile): Promise; +} + export interface DatafileManagerConfig { sdkKey: string; datafile?: string; @@ -361,6 +366,7 @@ export interface Config { errorNotifier?: OpaqueErrorNotifier; // user profile that contains user information userProfileService?: UserProfileService; + userProfileServiceAsync?: UserProfileServiceAsync; // dafault options for decide API defaultDecideOptions?: OptimizelyDecideOption[]; clientEngine?: string;