From cf3c7daef7ccfb8bf3385cc32c09a3ce668e72c9 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Fri, 28 Feb 2025 23:08:27 +0600 Subject: [PATCH 1/2] [FSSDK-11127] implement cmab service --- .../cmab/cmab_service.spec.ts | 379 ++++++++++++++++++ .../decision_service/cmab/cmab_service.ts | 152 +++++++ lib/shared_types.ts | 3 + 3 files changed, 534 insertions(+) create mode 100644 lib/core/decision_service/cmab/cmab_service.spec.ts create mode 100644 lib/core/decision_service/cmab/cmab_service.ts diff --git a/lib/core/decision_service/cmab/cmab_service.spec.ts b/lib/core/decision_service/cmab/cmab_service.spec.ts new file mode 100644 index 000000000..b008d4119 --- /dev/null +++ b/lib/core/decision_service/cmab/cmab_service.spec.ts @@ -0,0 +1,379 @@ +import { describe, it, expect, vi, Mocked, Mock, MockInstance, beforeEach, afterEach } from 'vitest'; + +import { DefaultCmabService } from './cmab_service'; +import { getMockSyncCache } from '../../../tests/mock/mock_cache'; +import { ProjectConfig } from '../../../project_config/project_config'; +import { OptimizelyDecideOption, UserAttributes } from '../../../shared_types'; +import OptimizelyUserContext from '../../../optimizely_user_context'; +import { validate as uuidValidate } from 'uuid'; + +const mockProjectConfig = (): ProjectConfig => ({ + experimentIdMap: { + '1234': { + id: '1234', + key: 'cmab_1', + cmab: { + attributeIds: ['66', '77', '88'], + } + }, + '5678': { + id: '5678', + key: 'cmab_2', + cmab: { + attributeIds: ['66', '99'], + } + }, + }, + attributeKeyMap: { + 'country': { + id: '66', + }, + 'age': { + id: '77', + }, + 'language': { + id: '88', + }, + 'gender': { + id: '99', + }, + } +} as any); + +const mockUserContext = (userId: string, attributes: UserAttributes): OptimizelyUserContext => new OptimizelyUserContext({ + userId, + attributes, +} as any); + +describe('DefaultCmabService', () => { + it('should fetch and return the variation from cmabClient using correct parameters', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValue('123'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + gender: 'male', + }); + + const ruleId = '1234'; + const variation = await cmabService.getDecision(projectConfig, userContext, ruleId, []); + + expect(variation.variationId).toEqual('123'); + expect(uuidValidate(variation.cmabUuid)).toBe(true); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledOnce(); + const [ruleIdArg, userIdArg, attributesArg, cmabUuidArg] = mockCmabClient.fetchDecision.mock.calls[0]; + expect(ruleIdArg).toEqual(ruleId); + expect(userIdArg).toEqual(userContext.getUserId()); + expect(attributesArg).toEqual({ + country: 'US', + age: '25', + }); + }); + + it('should filter attributes based on experiment cmab attributeIds before fetching variation', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValue('123'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + await cmabService.getDecision(projectConfig, userContext, '1234', []); + await cmabService.getDecision(projectConfig, userContext, '5678', []); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + expect(mockCmabClient.fetchDecision.mock.calls[0][2]).toEqual({ + country: 'US', + age: '25', + language: 'en', + }); + expect(mockCmabClient.fetchDecision.mock.calls[1][2]).toEqual({ + country: 'US', + gender: 'male' + }); + }); + + it('should cache the variation and return the same variation if relevant attributes have not changed', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext11 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation11 = await cmabService.getDecision(projectConfig, userContext11, '1234', []); + + const userContext12 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'female' + }); + + const variation12 = await cmabService.getDecision(projectConfig, userContext12, '1234', []); + expect(variation11.variationId).toEqual('123'); + expect(variation12.variationId).toEqual('123'); + expect(variation11.cmabUuid).toEqual(variation12.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1); + + const userContext21 = mockUserContext('user456', { + country: 'BD', + age: '30', + }); + + const variation21 = await cmabService.getDecision(projectConfig, userContext21, '5678', []); + + const userContext22 = mockUserContext('user456', { + country: 'BD', + age: '35', + }); + + const variation22 = await cmabService.getDecision(projectConfig, userContext22, '5678', []); + expect(variation21.variationId).toEqual('456'); + expect(variation22.variationId).toEqual('456'); + expect(variation21.cmabUuid).toEqual(variation22.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should not mix up the cache between different experiments', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext, '5678', []); + + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + }); + + it('should not mix up the cache between different users', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + + const userContext1 = mockUserContext('user123', { + country: 'US', + age: '25', + }); + + const userContext2 = mockUserContext('user456', { + country: 'US', + age: '25', + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should invalidate the cache and fetch a new variation if relevant attributes have changed', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext1 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', []); + + const userContext2 = mockUserContext('user123', { + country: 'US', + age: '50', + language: 'en', + gender: 'male' + }); + + const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should ignore the cache and fetch variation if IGNORE_CMAB_CACHE option is provided', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext, '1234', [ + OptimizelyDecideOption.IGNORE_CMAB_CACHE, + ]); + + const variation3 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + + expect(variation3.variationId).toEqual('123'); + expect(variation3.cmabUuid).toEqual(variation1.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); + + it('should reset the cache before fetching variation if RESET_CMAB_CACHE option is provided', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789') + .mockResolvedValueOnce('101112'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext1 = mockUserContext('user123', { + country: 'US', + age: '25' + }); + + const userContext2 = mockUserContext('user456', { + country: 'US', + age: '50' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext1, '1234', []); + expect(variation1.variationId).toEqual('123'); + + const variation2 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation2.variationId).toEqual('456'); + + const variation3 = await cmabService.getDecision(projectConfig, userContext1, '1234', [ + OptimizelyDecideOption.RESET_CMAB_CACHE, + ]); + expect(variation3.variationId).toEqual('789'); + + const variation4 = await cmabService.getDecision(projectConfig, userContext2, '1234', []); + expect(variation4.variationId).toEqual('101112'); + }); + + it('should invalidate the cache and fetch a new variation if INVALIDATE_USER_CMAB_CACHE option is provided', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation1 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + const variation2 = await cmabService.getDecision(projectConfig, userContext, '1234', [ + OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE, + ]); + + const variation3 = await cmabService.getDecision(projectConfig, userContext, '1234', []); + + expect(variation1.variationId).toEqual('123'); + expect(variation2.variationId).toEqual('456'); + expect(variation1.cmabUuid).not.toEqual(variation2.cmabUuid); + expect(variation3.variationId).toEqual('456'); + expect(variation2.cmabUuid).toEqual(variation3.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/core/decision_service/cmab/cmab_service.ts b/lib/core/decision_service/cmab/cmab_service.ts new file mode 100644 index 000000000..12aef2bb9 --- /dev/null +++ b/lib/core/decision_service/cmab/cmab_service.ts @@ -0,0 +1,152 @@ +/** + * Copyright 2025, 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 { LoggerFacade } from "../../../logging/logger"; +import OptimizelyUserContext from "../../../optimizely_user_context" +import { ProjectConfig } from "../../../project_config/project_config" +import { OptimizelyDecideOption, UserAttributes } from "../../../shared_types" +import { Cache } from "../../../utils/cache/cache"; +import { CmabClient } from "./cmab_client"; +import { v4 as uuidV4 } from 'uuid'; +import murmurhash from "murmurhash"; + +export type CmabDecision = { + variationId: string, + cmabUuid: string, +} + +export interface CmabService { + /** + * Get variation id for the user + * @param {OptimizelyUserContext} userContext + * @param {string} experimentId + * @param {OptimizelyDecideOption[]} options + * @return {Promise} + */ + getDecision( + projectConfig: ProjectConfig, + userContext: OptimizelyUserContext, + experimentId: string, + options: OptimizelyDecideOption[] + ): Promise +} + +export type CmabCacheValue = { + attributesHash: string, + variationId: string, + cmabUuid: string, +} + +export type CmabServiceOptions = { + logger?: LoggerFacade; + cmabCache: Cache; + cmabClient: CmabClient; +} + +export class DefaultCmabService implements CmabService { + private cmabCache: Cache; + private cmabClient: CmabClient; + private logger?: LoggerFacade; + + constructor(options: CmabServiceOptions) { + this.cmabCache = options.cmabCache; + this.cmabClient = options.cmabClient; + this.logger = options.logger; + } + + async getDecision( + projectConfig: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: string, + options: OptimizelyDecideOption[] + ): Promise { + const filteredAttributes = this.filterAttributes(projectConfig, userContext, ruleId); + + if (options.includes(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) { + return this.fetchVariation(ruleId, userContext.getUserId(), filteredAttributes); + } + + if (options.includes(OptimizelyDecideOption.RESET_CMAB_CACHE)) { + this.cmabCache.clear(); + } + + const cacheKey = this.getCacheKey(userContext.getUserId(), ruleId); + + if (options.includes(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE)) { + this.cmabCache.remove(cacheKey); + } + + const cachedValue = await this.cmabCache.get(cacheKey); + const attributesHash = String(murmurhash.v3(JSON.stringify(filteredAttributes))); + + if (cachedValue) { + if (cachedValue.attributesHash === attributesHash) { + return { variationId: cachedValue.variationId, cmabUuid: cachedValue.cmabUuid }; + } else { + this.cmabCache.remove(cacheKey); + } + } + + const variation = await this.fetchVariation(ruleId, userContext.getUserId(), filteredAttributes); + this.cmabCache.set(cacheKey, { + attributesHash, + variationId: variation.variationId, + cmabUuid: variation.cmabUuid, + }); + + return variation; + } + + private async fetchVariation( + ruleId: string, + userId: string, + attributes: UserAttributes, + ): Promise { + const cmabUuid = uuidV4(); + const variationId = await this.cmabClient.fetchDecision(ruleId, userId, attributes, cmabUuid); + return { variationId, cmabUuid }; + } + + private filterAttributes( + projectConfig: ProjectConfig, + userContext: OptimizelyUserContext, + ruleId: string + ): UserAttributes { + const filteredAttributes: UserAttributes = {}; + const attributes = userContext.getAttributes(); + + const experiment = projectConfig.experimentIdMap[ruleId]; + if (!experiment || !experiment.cmab) { + return filteredAttributes; + } + + const cmabAttributeIds = experiment.cmab.attributeIds; + + Object.keys(attributes).forEach((key) => { + const attributeId = projectConfig.attributeKeyMap[key].id; + if (cmabAttributeIds.includes(attributeId)) { + filteredAttributes[key] = attributes[key]; + } + }); + + return filteredAttributes; + } + + private getCacheKey(userId: string, ruleId: string): string { + const len = userId.length; + return `${len}-${userId}-${ruleId}`; + } +} diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 870b55ddc..4db7c0da1 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -252,6 +252,9 @@ export enum OptimizelyDecideOption { IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE', INCLUDE_REASONS = 'INCLUDE_REASONS', EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES', + IGNORE_CMAB_CACHE = 'IGNORE_CMAB_CACHE', + RESET_CMAB_CACHE = 'RESET_CMAB_CACHE', + INVALIDATE_USER_CMAB_CACHE = 'INVALIDATE_USER_CMAB_CACHE', } /** From d67cdd796fd2628017705756fb6623acdef428c9 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Thu, 6 Mar 2025 16:15:28 +0600 Subject: [PATCH 2/2] review updates --- .../cmab/cmab_service.spec.ts | 51 +++++++++++++++++-- .../decision_service/cmab/cmab_service.ts | 28 +++++----- lib/project_config/project_config.ts | 10 +++- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/lib/core/decision_service/cmab/cmab_service.spec.ts b/lib/core/decision_service/cmab/cmab_service.spec.ts index b008d4119..2e571932d 100644 --- a/lib/core/decision_service/cmab/cmab_service.spec.ts +++ b/lib/core/decision_service/cmab/cmab_service.spec.ts @@ -24,18 +24,22 @@ const mockProjectConfig = (): ProjectConfig => ({ } }, }, - attributeKeyMap: { - 'country': { + attributeIdMap: { + '66': { id: '66', + key: 'country', }, - 'age': { + '77': { id: '77', + key: 'age', }, - 'language': { + '88': { id: '88', + key: 'language', }, - 'gender': { + '99': { id: '99', + key: 'gender', }, } } as any); @@ -168,6 +172,43 @@ describe('DefaultCmabService', () => { expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(2); }); + it('should cache the variation and return the same variation if relevant attributes value have not changed but order changed', async () => { + const mockCmabClient = { + fetchDecision: vi.fn().mockResolvedValueOnce('123') + .mockResolvedValueOnce('456') + .mockResolvedValueOnce('789'), + }; + + const cmabService = new DefaultCmabService({ + cmabCache: getMockSyncCache(), + cmabClient: mockCmabClient, + }); + + const projectConfig = mockProjectConfig(); + const userContext11 = mockUserContext('user123', { + country: 'US', + age: '25', + language: 'en', + gender: 'male' + }); + + const variation11 = await cmabService.getDecision(projectConfig, userContext11, '1234', []); + + const userContext12 = mockUserContext('user123', { + gender: 'female', + language: 'en', + country: 'US', + age: '25', + }); + + const variation12 = await cmabService.getDecision(projectConfig, userContext12, '1234', []); + expect(variation11.variationId).toEqual('123'); + expect(variation12.variationId).toEqual('123'); + expect(variation11.cmabUuid).toEqual(variation12.cmabUuid); + + expect(mockCmabClient.fetchDecision).toHaveBeenCalledTimes(1); + }); + it('should not mix up the cache between different experiments', async () => { const mockCmabClient = { fetchDecision: vi.fn().mockResolvedValueOnce('123') diff --git a/lib/core/decision_service/cmab/cmab_service.ts b/lib/core/decision_service/cmab/cmab_service.ts index 12aef2bb9..2eaffd4fd 100644 --- a/lib/core/decision_service/cmab/cmab_service.ts +++ b/lib/core/decision_service/cmab/cmab_service.ts @@ -22,6 +22,7 @@ import { Cache } from "../../../utils/cache/cache"; import { CmabClient } from "./cmab_client"; import { v4 as uuidV4 } from 'uuid'; import murmurhash from "murmurhash"; +import { a } from "vitest/dist/chunks/suite.CcK46U-P"; export type CmabDecision = { variationId: string, @@ -32,14 +33,14 @@ export interface CmabService { /** * Get variation id for the user * @param {OptimizelyUserContext} userContext - * @param {string} experimentId + * @param {string} ruleId * @param {OptimizelyDecideOption[]} options * @return {Promise} */ getDecision( projectConfig: ProjectConfig, userContext: OptimizelyUserContext, - experimentId: string, + ruleId: string, options: OptimizelyDecideOption[] ): Promise } @@ -76,7 +77,7 @@ export class DefaultCmabService implements CmabService { const filteredAttributes = this.filterAttributes(projectConfig, userContext, ruleId); if (options.includes(OptimizelyDecideOption.IGNORE_CMAB_CACHE)) { - return this.fetchVariation(ruleId, userContext.getUserId(), filteredAttributes); + return this.fetchDecision(ruleId, userContext.getUserId(), filteredAttributes); } if (options.includes(OptimizelyDecideOption.RESET_CMAB_CACHE)) { @@ -90,7 +91,9 @@ export class DefaultCmabService implements CmabService { } const cachedValue = await this.cmabCache.get(cacheKey); - const attributesHash = String(murmurhash.v3(JSON.stringify(filteredAttributes))); + + const attributesJson = JSON.stringify(filteredAttributes, Object.keys(filteredAttributes).sort()); + const attributesHash = String(murmurhash.v3(attributesJson)); if (cachedValue) { if (cachedValue.attributesHash === attributesHash) { @@ -100,7 +103,7 @@ export class DefaultCmabService implements CmabService { } } - const variation = await this.fetchVariation(ruleId, userContext.getUserId(), filteredAttributes); + const variation = await this.fetchDecision(ruleId, userContext.getUserId(), filteredAttributes); this.cmabCache.set(cacheKey, { attributesHash, variationId: variation.variationId, @@ -110,7 +113,7 @@ export class DefaultCmabService implements CmabService { return variation; } - private async fetchVariation( + private async fetchDecision( ruleId: string, userId: string, attributes: UserAttributes, @@ -126,7 +129,7 @@ export class DefaultCmabService implements CmabService { ruleId: string ): UserAttributes { const filteredAttributes: UserAttributes = {}; - const attributes = userContext.getAttributes(); + const userAttributes = userContext.getAttributes(); const experiment = projectConfig.experimentIdMap[ruleId]; if (!experiment || !experiment.cmab) { @@ -134,11 +137,12 @@ export class DefaultCmabService implements CmabService { } const cmabAttributeIds = experiment.cmab.attributeIds; - - Object.keys(attributes).forEach((key) => { - const attributeId = projectConfig.attributeKeyMap[key].id; - if (cmabAttributeIds.includes(attributeId)) { - filteredAttributes[key] = attributes[key]; + + cmabAttributeIds.forEach((aid) => { + const attribute = projectConfig.attributeIdMap[aid]; + + if (userAttributes.hasOwnProperty(attribute.key)) { + filteredAttributes[attribute.key] = userAttributes[attribute.key]; } }); diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 756c8c058..92b9c1ac5 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -88,6 +88,7 @@ export interface ProjectConfig { eventKeyMap: { [key: string]: Event }; audiences: Audience[]; attributeKeyMap: { [key: string]: { id: string } }; + attributeIdMap: { [id: string]: { key: string } }; variationIdMap: { [id: string]: OptimizelyVariation }; variationVariableUsageMap: { [id: string]: VariableUsageMap }; audiencesById: { [id: string]: Audience }; @@ -178,7 +179,14 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str ...keyBy(projectConfig.typedAudiences, 'id'), } - projectConfig.attributeKeyMap = keyBy(projectConfig.attributes, 'key'); + projectConfig.attributes = projectConfig.attributes || []; + projectConfig.attributeKeyMap = {}; + projectConfig.attributeIdMap = {}; + projectConfig.attributes.forEach(attribute => { + projectConfig.attributeKeyMap[attribute.key] = attribute; + projectConfig.attributeIdMap[attribute.id] = attribute; + }); + projectConfig.eventKeyMap = keyBy(projectConfig.events, 'key'); projectConfig.groupIdMap = keyBy(projectConfig.groups, 'id');