Skip to content

Commit c397ca4

Browse files
authored
Add support for contextual bandits (#64)
* existing tests passing * better test helper * tests passing * use latest common SDK * define mock bandit stores for non bandit tests * bump versin number
1 parent 9a34f5e commit c397ca4

9 files changed

+240
-88
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
2+
3+
[Home](./index.md) &gt; [@eppo/node-server-sdk](./node-server-sdk.md) &gt; [IClientConfig](./node-server-sdk.iclientconfig.md) &gt; [banditLogger](./node-server-sdk.iclientconfig.banditlogger.md)
4+
5+
## IClientConfig.banditLogger property
6+
7+
Logging implementation to send bandit actions to your data warehouse
8+
9+
**Signature:**
10+
11+
```typescript
12+
banditLogger?: IBanditLogger;
13+
```

docs/node-server-sdk.iclientconfig.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface IClientConfig
1818
| --- | --- | --- | --- |
1919
| [apiKey](./node-server-sdk.iclientconfig.apikey.md) | | string | Eppo API key |
2020
| [assignmentLogger](./node-server-sdk.iclientconfig.assignmentlogger.md) | | IAssignmentLogger | Pass a logging implementation to send variation assignments to your data warehouse. |
21+
| [banditLogger?](./node-server-sdk.iclientconfig.banditlogger.md) | | IBanditLogger | _(Optional)_ Logging implementation to send bandit actions to your data warehouse |
2122
| [baseUrl?](./node-server-sdk.iclientconfig.baseurl.md) | | string | _(Optional)_ Base URL of the Eppo API. Clients should use the default setting in most cases. |
2223
| [numInitialRequestRetries?](./node-server-sdk.iclientconfig.numinitialrequestretries.md) | | number | _(Optional)_ Number of additional times the initial configuration request will be attempted if it fails. This is the request servers typically synchronously wait for completion. A small wait will be done between requests. (Default: 1) |
2324
| [numPollRequestRetries?](./node-server-sdk.iclientconfig.numpollrequestretries.md) | | number | _(Optional)_ Number of additional times polling for updated configurations will be attempted before giving up. Polling is done after a successful initial request. Subsequent attempts are done using an exponential backoff. (Default: 7) |

node-server-sdk.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { IAssignmentEvent } from '@eppo/js-client-sdk-common';
88
import { IAssignmentLogger } from '@eppo/js-client-sdk-common';
9+
import { IBanditLogger } from '@eppo/js-client-sdk-common';
910
import { IEppoClient } from '@eppo/js-client-sdk-common';
1011

1112
// @public
@@ -19,6 +20,7 @@ export { IAssignmentLogger }
1920
export interface IClientConfig {
2021
apiKey: string;
2122
assignmentLogger: IAssignmentLogger;
23+
banditLogger?: IBanditLogger;
2224
baseUrl?: string;
2325
numInitialRequestRetries?: number;
2426
numPollRequestRetries?: number;

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/node-server-sdk",
3-
"version": "3.0.2",
3+
"version": "3.1.0",
44
"description": "Eppo node server SDK",
55
"main": "dist/index.js",
66
"files": [
@@ -29,7 +29,7 @@
2929
},
3030
"homepage": "https://github.com/Eppo-exp/node-server-sdk#readme",
3131
"dependencies": {
32-
"@eppo/js-client-sdk-common": "3.0.6",
32+
"@eppo/js-client-sdk-common": "3.5.0",
3333
"lru-cache": "^10.0.1"
3434
},
3535
"devDependencies": {
@@ -59,4 +59,4 @@
5959
"node": ">=18.x",
6060
"yarn": "1.x"
6161
}
62-
}
62+
}

src/index.spec.ts

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,22 @@ import {
66
IConfigurationStore,
77
Flag,
88
VariationType,
9+
IBanditEvent,
10+
IBanditLogger,
911
} from '@eppo/js-client-sdk-common';
12+
import { BanditParameters, BanditVariation } from '@eppo/js-client-sdk-common/dist/interfaces';
13+
import { ContextAttributes } from '@eppo/js-client-sdk-common/dist/types';
1014
import * as td from 'testdouble';
1115

12-
import apiServer, { TEST_SERVER_PORT } from '../test/mockApiServer';
16+
import apiServer, { TEST_BANDIT_API_KEY, TEST_SERVER_PORT } from '../test/mockApiServer';
1317
import {
18+
ASSIGNMENT_TEST_DATA_DIR,
19+
BANDIT_TEST_DATA_DIR,
20+
BanditTestCase,
1421
getTestAssignments,
1522
IAssignmentTestCase,
16-
readAssignmentTestData,
1723
SubjectTestCase,
24+
testCasesByFileName,
1825
validateTestAssignments,
1926
} from '../test/testHelpers';
2027

@@ -29,6 +36,11 @@ describe('EppoClient E2E test', () => {
2936
},
3037
};
3138

39+
// These two stores should not be used as this file doesn't test bandits, but we want them to be defined so bandit
40+
// functionality is still "on" for the client when we explicitly instantiate the client (vs. using init())
41+
const mockBanditVariationStore = td.object<IConfigurationStore<BanditVariation[]>>();
42+
const mockBanditModelStore = td.object<IConfigurationStore<BanditParameters>>();
43+
3244
const flagKey = 'mock-experiment';
3345

3446
// Configuration for a single flag within the UFC.
@@ -139,45 +151,50 @@ describe('EppoClient E2E test', () => {
139151
});
140152
});
141153

142-
describe('UFC General Test Cases', () => {
143-
it.each(readAssignmentTestData())(
144-
'test variation assignment splits',
145-
async ({ flag, variationType, defaultValue, subjects }: IAssignmentTestCase) => {
146-
const client = getInstance();
147-
148-
let assignments: {
149-
subject: SubjectTestCase;
150-
assignment: string | boolean | number | object;
151-
}[] = [];
152-
153-
const typeAssignmentFunctions = {
154-
[VariationType.BOOLEAN]: client.getBoolAssignment.bind(client),
155-
[VariationType.NUMERIC]: client.getNumericAssignment.bind(client),
156-
[VariationType.INTEGER]: client.getIntegerAssignment.bind(client),
157-
[VariationType.STRING]: client.getStringAssignment.bind(client),
158-
[VariationType.JSON]: client.getJSONAssignment.bind(client),
159-
};
154+
describe('Shared UFC General Test Cases', () => {
155+
const testCases = testCasesByFileName<IAssignmentTestCase>(ASSIGNMENT_TEST_DATA_DIR);
156+
157+
it.each(Object.keys(testCases))('test variation assignment splits - %s', async (fileName) => {
158+
const { flag, variationType, defaultValue, subjects } = testCases[fileName];
159+
const client = getInstance();
160+
161+
let assignments: {
162+
subject: SubjectTestCase;
163+
assignment: string | boolean | number | object;
164+
}[] = [];
165+
166+
const typeAssignmentFunctions = {
167+
[VariationType.BOOLEAN]: client.getBooleanAssignment.bind(client),
168+
[VariationType.NUMERIC]: client.getNumericAssignment.bind(client),
169+
[VariationType.INTEGER]: client.getIntegerAssignment.bind(client),
170+
[VariationType.STRING]: client.getStringAssignment.bind(client),
171+
[VariationType.JSON]: client.getJSONAssignment.bind(client),
172+
};
173+
174+
const assignmentFn = typeAssignmentFunctions[variationType];
175+
if (!assignmentFn) {
176+
throw new Error(`Unknown variation type: ${variationType}`);
177+
}
160178

161-
const assignmentFn = typeAssignmentFunctions[variationType];
162-
if (!assignmentFn) {
163-
throw new Error(`Unknown variation type: ${variationType}`);
164-
}
179+
assignments = getTestAssignments(
180+
{ flag, variationType, defaultValue, subjects },
181+
assignmentFn,
182+
false,
183+
);
165184

166-
assignments = getTestAssignments(
167-
{ flag, variationType, defaultValue, subjects },
168-
assignmentFn,
169-
false,
170-
);
171-
172-
validateTestAssignments(assignments, flag);
173-
},
174-
);
185+
validateTestAssignments(assignments, flag);
186+
});
175187
});
176188

177189
it('returns the default value when ufc config is absent', () => {
178190
const mockConfigStore = td.object<IConfigurationStore<Flag>>();
179191
td.when(mockConfigStore.get(flagKey)).thenReturn(null);
180-
const client = new EppoClient(mockConfigStore, requestParamsStub);
192+
const client = new EppoClient(
193+
mockConfigStore,
194+
mockBanditVariationStore,
195+
mockBanditModelStore,
196+
requestParamsStub,
197+
);
181198
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
182199
expect(assignment).toEqual('default-value');
183200
});
@@ -186,9 +203,14 @@ describe('EppoClient E2E test', () => {
186203
const mockConfigStore = td.object<IConfigurationStore<Flag>>();
187204
td.when(mockConfigStore.get(flagKey)).thenReturn(mockUfcFlagConfig);
188205
const subjectAttributes = { foo: 3 };
189-
const client = new EppoClient(mockConfigStore, requestParamsStub);
206+
const client = new EppoClient(
207+
mockConfigStore,
208+
mockBanditVariationStore,
209+
mockBanditModelStore,
210+
requestParamsStub,
211+
);
190212
const mockLogger = td.object<IAssignmentLogger>();
191-
client.setLogger(mockLogger);
213+
client.setAssignmentLogger(mockLogger);
192214
const assignment = client.getStringAssignment(
193215
flagKey,
194216
'subject-10',
@@ -211,12 +233,17 @@ describe('EppoClient E2E test', () => {
211233
const mockConfigStore = td.object<IConfigurationStore<Flag>>();
212234
td.when(mockConfigStore.get(flagKey)).thenReturn(mockUfcFlagConfig);
213235
const subjectAttributes = { foo: 3 };
214-
const client = new EppoClient(mockConfigStore, requestParamsStub);
236+
const client = new EppoClient(
237+
mockConfigStore,
238+
mockBanditVariationStore,
239+
mockBanditModelStore,
240+
requestParamsStub,
241+
);
215242
const mockLogger = td.object<IAssignmentLogger>();
216243
td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(
217244
new Error('logging error'),
218245
);
219-
client.setLogger(mockLogger);
246+
client.setAssignmentLogger(mockLogger);
220247
const assignment = client.getStringAssignment(
221248
flagKey,
222249
'subject-10',
@@ -227,6 +254,66 @@ describe('EppoClient E2E test', () => {
227254
});
228255
});
229256

257+
describe('Shared Bandit Test Cases', () => {
258+
beforeAll(async () => {
259+
const dummyBanditLogger: IBanditLogger = {
260+
logBanditAction(banditEvent: IBanditEvent) {
261+
console.log(
262+
`Bandit ${banditEvent.bandit} assigned ${banditEvent.subject} the action ${banditEvent.action}`,
263+
);
264+
},
265+
};
266+
267+
await init({
268+
apiKey: TEST_BANDIT_API_KEY, // Flag to dummy test server we want bandit-related files
269+
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
270+
assignmentLogger: mockLogger,
271+
banditLogger: dummyBanditLogger,
272+
});
273+
});
274+
275+
const testCases = testCasesByFileName<BanditTestCase>(BANDIT_TEST_DATA_DIR);
276+
277+
it.each(Object.keys(testCases))('Shared bandit test case - %s', async (fileName: string) => {
278+
const { flag: flagKey, defaultValue, subjects } = testCases[fileName];
279+
let numAssignmentsChecked = 0;
280+
subjects.forEach((subject) => {
281+
// test files have actions as an array, so we convert them to a map as expected by the client
282+
const actions: Record<string, ContextAttributes> = {};
283+
subject.actions.forEach((action) => {
284+
actions[action.actionKey] = {
285+
numericAttributes: action.numericAttributes,
286+
categoricalAttributes: action.categoricalAttributes,
287+
};
288+
});
289+
290+
// get the bandit assignment for the test case
291+
const banditAssignment = getInstance().getBanditAction(
292+
flagKey,
293+
subject.subjectKey,
294+
subject.subjectAttributes,
295+
actions,
296+
defaultValue,
297+
);
298+
299+
// Do this check in addition to assertions to provide helpful information on exactly which
300+
// evaluation failed to produce an expected result
301+
if (
302+
banditAssignment.variation !== subject.assignment.variation ||
303+
banditAssignment.action !== subject.assignment.action
304+
) {
305+
console.error(`Unexpected result for flag ${flagKey} and subject ${subject.subjectKey}`);
306+
}
307+
308+
expect(banditAssignment.variation).toBe(subject.assignment.variation);
309+
expect(banditAssignment.action).toBe(subject.assignment.action);
310+
numAssignmentsChecked += 1;
311+
});
312+
// Ensure that this test case correctly checked some test assignments
313+
expect(numAssignmentsChecked).toBeGreaterThan(0);
314+
});
315+
});
316+
230317
describe('initialization errors', () => {
231318
const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT;
232319
const mockConfigResponse = {
@@ -236,9 +323,9 @@ describe('EppoClient E2E test', () => {
236323
};
237324

238325
it('retries initial configuration request before resolving', async () => {
239-
td.replace(HttpClient.prototype, 'get');
326+
td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration');
240327
let callCount = 0;
241-
td.when(HttpClient.prototype.get(td.matchers.anything())).thenDo(() => {
328+
td.when(HttpClient.prototype.getUniversalFlagConfiguration()).thenDo(() => {
242329
if (++callCount === 1) {
243330
// Throw an error for the first call
244331
throw new Error('Intentional Thrown Error For Test');
@@ -266,9 +353,9 @@ describe('EppoClient E2E test', () => {
266353
});
267354

268355
it('gives up initial request and throws error after hitting max retries', async () => {
269-
td.replace(HttpClient.prototype, 'get');
356+
td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration');
270357
let callCount = 0;
271-
td.when(HttpClient.prototype.get(td.matchers.anything())).thenDo(async () => {
358+
td.when(HttpClient.prototype.getUniversalFlagConfiguration()).thenDo(async () => {
272359
callCount += 1;
273360
throw new Error('Intentional Thrown Error For Test');
274361
});
@@ -298,9 +385,9 @@ describe('EppoClient E2E test', () => {
298385
});
299386

300387
it('gives up initial request but still polls later if configured to do so', async () => {
301-
td.replace(HttpClient.prototype, 'get');
388+
td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration');
302389
let callCount = 0;
303-
td.when(HttpClient.prototype.get(td.matchers.anything())).thenDo(() => {
390+
td.when(HttpClient.prototype.getUniversalFlagConfiguration()).thenDo(() => {
304391
if (++callCount <= 2) {
305392
// Throw an error for the first call
306393
throw new Error('Intentional Thrown Error For Test');

src/index.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import {
66
FlagConfigurationRequestParameters,
77
MemoryOnlyConfigurationStore,
88
Flag,
9+
IBanditLogger,
910
} from '@eppo/js-client-sdk-common';
10-
import { ObfuscatedFlag } from '@eppo/js-client-sdk-common/dist/interfaces';
11+
import { BanditParameters, BanditVariation } from '@eppo/js-client-sdk-common/dist/interfaces';
1112

1213
import { sdkName, sdkVersion } from './sdk-data';
1314

14-
1515
/**
1616
* Configuration used for initializing the Eppo client
1717
* @public
@@ -33,6 +33,11 @@ export interface IClientConfig {
3333
*/
3434
assignmentLogger: IAssignmentLogger;
3535

36+
/**
37+
* Logging implementation to send bandit actions to your data warehouse
38+
*/
39+
banditLogger?: IBanditLogger;
40+
3641
/***
3742
* Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000)
3843
*/
@@ -76,7 +81,6 @@ let clientInstance: IEppoClient;
7681
*/
7782
export async function init(config: IClientConfig): Promise<IEppoClient> {
7883
validation.validateNotBlank(config.apiKey, 'API key required');
79-
const configurationStore = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
8084

8185
const requestConfiguration: FlagConfigurationRequestParameters = {
8286
apiKey: config.apiKey,
@@ -86,13 +90,25 @@ export async function init(config: IClientConfig): Promise<IEppoClient> {
8690
requestTimeoutMs: config.requestTimeoutMs ?? undefined,
8791
numInitialRequestRetries: config.numInitialRequestRetries ?? undefined,
8892
numPollRequestRetries: config.numPollRequestRetries ?? undefined,
89-
pollAfterSuccessfulInitialization: true, // For servers we always want to keep polling for the life of the server
93+
pollAfterSuccessfulInitialization: true, // For servers, we always want to keep polling for the life of the server
9094
pollAfterFailedInitialization: config.pollAfterFailedInitialization ?? false,
9195
throwOnFailedInitialization: config.throwOnFailedInitialization ?? true,
9296
};
9397

94-
clientInstance = new EppoClient(configurationStore, requestConfiguration);
95-
clientInstance.setLogger(config.assignmentLogger);
98+
const flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
99+
const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
100+
const banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
101+
102+
clientInstance = new EppoClient(
103+
flagConfigurationStore,
104+
banditVariationConfigurationStore,
105+
banditModelConfigurationStore,
106+
requestConfiguration,
107+
);
108+
clientInstance.setAssignmentLogger(config.assignmentLogger);
109+
if (config.banditLogger) {
110+
clientInstance.setBanditLogger(config.banditLogger);
111+
}
96112

97113
// default to LRU cache with 50_000 entries.
98114
// we estimate this will use no more than 10 MB of memory

0 commit comments

Comments
 (0)