Skip to content

Commit 3f84c6b

Browse files
add bandits configuration to offline bootstrap in precomputed client … (#195)
* add bandits configuration to offline bootstrap in precomputed client (FF-3796) * add specs * add bandit functions * remove details; change signature * payload * basic e2e test * 4.9.0-alpha.1; fix test helper * allow bandit evaluation reason to be null * fix lock * fix registry * nullish
1 parent 325a660 commit 3f84c6b

11 files changed

+1685
-1231
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.8.1",
3+
"version": "4.9.0-alpha.1",
44
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
55
"main": "dist/index.js",
66
"files": [
@@ -78,4 +78,4 @@
7878
"uuid": "^11.0.5"
7979
},
8080
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
81-
}
81+
}

src/bandit-logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface IBanditEvent {
1515
actionNumericAttributes: Attributes;
1616
actionCategoricalAttributes: Attributes;
1717
metaData?: Record<string, unknown>;
18-
evaluationDetails: IFlagEvaluationDetails;
18+
evaluationDetails: IFlagEvaluationDetails | null;
1919
}
2020

2121
export interface IBanditLogger {

src/client/eppo-client-with-bandits.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ describe('EppoClient Bandits E2E test', () => {
186186
expect(banditEvent.actionCategoricalAttributes).toStrictEqual({ loyalty_tier: 'bronze' });
187187
expect(banditEvent.metaData?.obfuscated).toBe(false);
188188

189-
expect(banditEvent.evaluationDetails.configFetchedAt).toBeTruthy();
190-
expect(typeof banditEvent.evaluationDetails.configFetchedAt).toBe('string');
189+
expect(banditEvent.evaluationDetails?.configFetchedAt).toBeTruthy();
190+
expect(typeof banditEvent.evaluationDetails?.configFetchedAt).toBe('string');
191191
const expectedEvaluationDetails: IFlagEvaluationDetails = {
192192
configFetchedAt: expect.any(String),
193193
configPublishedAt: '2024-04-17T19:40:53.716Z',
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
readMockConfigurationWireResponse,
3+
MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE,
4+
} from '../../test/testHelpers';
5+
import ApiEndpoints from '../api-endpoints';
6+
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
7+
import FetchHttpClient from '../http-client';
8+
import { PrecomputedFlag, IObfuscatedPrecomputedBandit } from '../interfaces';
9+
import PrecomputedFlagRequestor from '../precomputed-requestor';
10+
11+
import EppoPrecomputedClient from './eppo-precomputed-client';
12+
13+
describe('EppoPrecomputedClient Bandits E2E test', () => {
14+
const precomputedFlagStore = new MemoryOnlyConfigurationStore<PrecomputedFlag>();
15+
const precomputedBanditStore = new MemoryOnlyConfigurationStore<IObfuscatedPrecomputedBandit>();
16+
let client: EppoPrecomputedClient;
17+
const mockLogAssignment = jest.fn();
18+
const mockLogBanditAction = jest.fn();
19+
20+
const salt = 'NaCl';
21+
22+
const precomputedConfigurationWire = readMockConfigurationWireResponse(
23+
MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE,
24+
);
25+
const parsedPrecomputedResponse = JSON.parse(precomputedConfigurationWire).precomputed.response;
26+
27+
const testModes = ['offline', 'online'] as const;
28+
29+
testModes.forEach((mode) => {
30+
describe(`${mode} mode`, () => {
31+
beforeAll(async () => {
32+
if (mode === 'online') {
33+
// Mock fetch for online mode
34+
global.fetch = jest.fn((url: string) => {
35+
return Promise.resolve({
36+
ok: true,
37+
status: 200,
38+
json: () => Promise.resolve(parsedPrecomputedResponse),
39+
});
40+
}) as jest.Mock;
41+
42+
const apiEndpoints = new ApiEndpoints({
43+
baseUrl: 'http://127.0.0.1:4000',
44+
queryParams: {
45+
apiKey: 'dummy',
46+
sdkName: 'js-client-sdk-common',
47+
sdkVersion: '1.0.0',
48+
},
49+
});
50+
const httpClient = new FetchHttpClient(apiEndpoints, 1000);
51+
const configurationRequestor = new PrecomputedFlagRequestor(
52+
httpClient,
53+
precomputedFlagStore,
54+
'test-subject',
55+
{
56+
numericAttributes: {},
57+
categoricalAttributes: {},
58+
},
59+
precomputedBanditStore,
60+
{
61+
banner_bandit_flag: {
62+
nike: {
63+
numericAttributes: { brand_affinity: -2.5 },
64+
categoricalAttributes: { loyalty_tier: 'bronze' },
65+
},
66+
},
67+
},
68+
);
69+
await configurationRequestor.fetchAndStorePrecomputedFlags();
70+
} else if (mode === 'offline') {
71+
// Offline mode: directly populate stores with precomputed response
72+
await precomputedFlagStore.setEntries(parsedPrecomputedResponse.flags);
73+
await precomputedBanditStore.setEntries(parsedPrecomputedResponse.bandits);
74+
}
75+
});
76+
77+
beforeEach(() => {
78+
// Create precomputed client with required subject and stores
79+
client = new EppoPrecomputedClient({
80+
precomputedFlagStore,
81+
precomputedBanditStore,
82+
subject: {
83+
subjectKey: 'test-subject',
84+
subjectAttributes: {
85+
numericAttributes: {},
86+
categoricalAttributes: {},
87+
},
88+
},
89+
});
90+
client.setAssignmentLogger({ logAssignment: mockLogAssignment });
91+
client.setBanditLogger({ logBanditAction: mockLogBanditAction });
92+
jest.clearAllMocks();
93+
});
94+
95+
afterEach(() => {
96+
jest.clearAllMocks();
97+
});
98+
99+
afterAll(() => {
100+
jest.restoreAllMocks();
101+
});
102+
103+
it(`should return the default action for the banner_bandit_flag in ${mode} mode`, () => {
104+
const precomputedConfiguration = client.getBanditAction('banner_bandit_flag', 'nike');
105+
expect(precomputedConfiguration).toEqual({ action: null, variation: 'nike' });
106+
});
107+
});
108+
});
109+
});

src/client/eppo-precomputed-client.spec.ts

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ import { IConfigurationStore } from '../configuration-store/configuration-store'
1616
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1717
import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants';
1818
import FetchHttpClient from '../http-client';
19-
import { FormatEnum, PrecomputedFlag, VariationType } from '../interfaces';
19+
import {
20+
FormatEnum,
21+
IObfuscatedPrecomputedBandit,
22+
PrecomputedFlag,
23+
VariationType,
24+
} from '../interfaces';
2025
import { decodeBase64, encodeBase64, getMD5Hash } from '../obfuscation';
2126
import PrecomputedRequestor from '../precomputed-requestor';
2227

@@ -859,3 +864,174 @@ describe('EppoPrecomputedClient E2E test', () => {
859864
});
860865
});
861866
});
867+
868+
describe('Precomputed Bandit Store', () => {
869+
let precomputedFlagStore: IConfigurationStore<PrecomputedFlag>;
870+
let precomputedBanditStore: IConfigurationStore<IObfuscatedPrecomputedBandit>;
871+
let subject: Subject;
872+
let mockLogger: IAssignmentLogger;
873+
874+
beforeEach(() => {
875+
precomputedFlagStore = new MemoryOnlyConfigurationStore<PrecomputedFlag>();
876+
precomputedBanditStore = new MemoryOnlyConfigurationStore<IObfuscatedPrecomputedBandit>();
877+
subject = {
878+
subjectKey: 'test-subject',
879+
subjectAttributes: { attr1: 'value1' },
880+
};
881+
mockLogger = td.object<IAssignmentLogger>();
882+
});
883+
884+
it('prints errors if initialized with a bandit store that is not initialized and without requestParameters', () => {
885+
const loggerErrorSpy = jest.spyOn(logger, 'error');
886+
const loggerWarnSpy = jest.spyOn(logger, 'warn');
887+
888+
const client = new EppoPrecomputedClient({
889+
precomputedFlagStore,
890+
precomputedBanditStore,
891+
subject,
892+
});
893+
894+
expect(loggerErrorSpy).toHaveBeenCalledWith(
895+
'[Eppo SDK] EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided',
896+
);
897+
expect(loggerErrorSpy).toHaveBeenCalledWith(
898+
'[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided',
899+
);
900+
expect(loggerErrorSpy).toHaveBeenCalledWith(
901+
'[Eppo SDK] Passing banditOptions without requestParameters requires an initialized precomputedBanditStore',
902+
);
903+
expect(loggerWarnSpy).toHaveBeenCalledWith(
904+
'[Eppo SDK] EppoPrecomputedClient missing or empty salt for precomputedBanditStore',
905+
);
906+
907+
loggerErrorSpy.mockRestore();
908+
loggerWarnSpy.mockRestore();
909+
});
910+
911+
it('prints only salt-related errors if stores are initialized but missing salts', async () => {
912+
const loggerErrorSpy = jest.spyOn(logger, 'error');
913+
const loggerWarnSpy = jest.spyOn(logger, 'warn');
914+
915+
await precomputedFlagStore.setEntries({
916+
'test-flag': {
917+
flagKey: 'test-flag',
918+
variationType: VariationType.STRING,
919+
variationKey: encodeBase64('control'),
920+
variationValue: encodeBase64('test-value'),
921+
allocationKey: encodeBase64('allocation-1'),
922+
doLog: true,
923+
extraLogging: {},
924+
},
925+
});
926+
927+
await precomputedBanditStore.setEntries({
928+
'test-bandit': {
929+
banditKey: encodeBase64('test-bandit'),
930+
action: encodeBase64('action1'),
931+
modelVersion: encodeBase64('v1'),
932+
actionProbability: 0.5,
933+
optimalityGap: 0.1,
934+
actionNumericAttributes: {
935+
[encodeBase64('attr1')]: encodeBase64('1.0'),
936+
},
937+
actionCategoricalAttributes: {
938+
[encodeBase64('attr2')]: encodeBase64('value2'),
939+
},
940+
},
941+
});
942+
943+
const client = new EppoPrecomputedClient({
944+
precomputedFlagStore,
945+
precomputedBanditStore,
946+
subject,
947+
});
948+
949+
expect(loggerErrorSpy).toHaveBeenCalledWith(
950+
'[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided',
951+
);
952+
expect(loggerWarnSpy).toHaveBeenCalledWith(
953+
'[Eppo SDK] EppoPrecomputedClient missing or empty salt for precomputedBanditStore',
954+
);
955+
956+
loggerErrorSpy.mockRestore();
957+
loggerWarnSpy.mockRestore();
958+
});
959+
960+
it('initializes correctly with both stores having salts', async () => {
961+
const loggerErrorSpy = jest.spyOn(logger, 'error');
962+
const loggerWarnSpy = jest.spyOn(logger, 'warn');
963+
964+
precomputedFlagStore.salt = 'flag-salt';
965+
precomputedBanditStore.salt = 'bandit-salt';
966+
967+
await precomputedFlagStore.setEntries({
968+
'test-flag': {
969+
flagKey: 'test-flag',
970+
variationType: VariationType.STRING,
971+
variationKey: encodeBase64('control'),
972+
variationValue: encodeBase64('test-value'),
973+
allocationKey: encodeBase64('allocation-1'),
974+
doLog: true,
975+
extraLogging: {},
976+
},
977+
});
978+
979+
await precomputedBanditStore.setEntries({
980+
'test-bandit': {
981+
banditKey: encodeBase64('test-bandit'),
982+
action: encodeBase64('action1'),
983+
modelVersion: encodeBase64('v1'),
984+
actionProbability: 0.5,
985+
optimalityGap: 0.1,
986+
actionNumericAttributes: {
987+
[encodeBase64('attr1')]: encodeBase64('1.0'),
988+
},
989+
actionCategoricalAttributes: {
990+
[encodeBase64('attr2')]: encodeBase64('value2'),
991+
},
992+
},
993+
});
994+
995+
const client = new EppoPrecomputedClient({
996+
precomputedFlagStore,
997+
precomputedBanditStore,
998+
subject,
999+
});
1000+
1001+
expect(loggerErrorSpy).not.toHaveBeenCalled();
1002+
expect(loggerWarnSpy).not.toHaveBeenCalled();
1003+
1004+
loggerErrorSpy.mockRestore();
1005+
loggerWarnSpy.mockRestore();
1006+
});
1007+
1008+
it('allows initialization without bandit store', async () => {
1009+
const loggerErrorSpy = jest.spyOn(logger, 'error');
1010+
const loggerWarnSpy = jest.spyOn(logger, 'warn');
1011+
1012+
precomputedFlagStore.salt = 'flag-salt';
1013+
1014+
await precomputedFlagStore.setEntries({
1015+
'test-flag': {
1016+
flagKey: 'test-flag',
1017+
variationType: VariationType.STRING,
1018+
variationKey: encodeBase64('control'),
1019+
variationValue: encodeBase64('test-value'),
1020+
allocationKey: encodeBase64('allocation-1'),
1021+
doLog: true,
1022+
extraLogging: {},
1023+
},
1024+
});
1025+
1026+
const client = new EppoPrecomputedClient({
1027+
precomputedFlagStore,
1028+
subject,
1029+
});
1030+
1031+
expect(loggerErrorSpy).not.toHaveBeenCalled();
1032+
expect(loggerWarnSpy).not.toHaveBeenCalled();
1033+
1034+
loggerErrorSpy.mockRestore();
1035+
loggerWarnSpy.mockRestore();
1036+
});
1037+
});

0 commit comments

Comments
 (0)