Skip to content

Commit 32ae00a

Browse files
yfrancisgreghuels
andauthored
feat: add local override functionality (#184)
* Add local override functionality * Hash flag keys before looking up * Re-use flag evaluation details builder * Revert "Hash flag keys before looking up" This reverts commit 8cdd8ec. * Remove unused constant * Add test coverage for override functionality * change overrides store type to ISyncStore * Better test for assignment cache behavior with overrides * Add unsetOverridesStore method * Add a test for unsetting overrides store * Move overridesStore param and decl closer to other stores * Add prefix to allocation key for overrides for clarity * Move evaluation details construction into helper * style: move overrides store param next to other stores * Add overrides to precomputed client * Add test coverage for overrides with the precomputed client * Fix type of overridesStore in the client constructor, should be ISyncStore * Add getter for all active override keys * Rename overridesStore to overrideStore * bump SDK version to 4.10.0 --------- Co-authored-by: Greg Huels <[email protected]>
1 parent f6c3d4f commit 32ae00a

File tree

6 files changed

+567
-7
lines changed

6 files changed

+567
-7
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.9.0",
3+
"version": "4.10.0",
44
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
55
"main": "dist/index.js",
66
"files": [

src/client/eppo-client.spec.ts

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
validateTestAssignments,
1515
} from '../../test/testHelpers';
1616
import { IAssignmentLogger } from '../assignment-logger';
17+
import { AssignmentCache } from '../cache/abstract-assignment-cache';
1718
import {
1819
IConfigurationWire,
1920
IObfuscatedPrecomputedConfigurationResponse,
@@ -23,7 +24,7 @@ import { IConfigurationStore } from '../configuration-store/configuration-store'
2324
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
2425
import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
2526
import { decodePrecomputedFlag } from '../decoding';
26-
import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
27+
import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces';
2728
import { getMD5Hash } from '../obfuscation';
2829
import { AttributeType } from '../types';
2930

@@ -945,4 +946,225 @@ describe('EppoClient E2E test', () => {
945946
);
946947
});
947948
});
949+
950+
describe('flag overrides', () => {
951+
let client: EppoClient;
952+
let mockLogger: IAssignmentLogger;
953+
let overrideStore: IConfigurationStore<Variation>;
954+
955+
beforeEach(() => {
956+
storage.setEntries({ [flagKey]: mockFlag });
957+
mockLogger = td.object<IAssignmentLogger>();
958+
overrideStore = new MemoryOnlyConfigurationStore<Variation>();
959+
client = new EppoClient({
960+
flagConfigurationStore: storage,
961+
overrideStore: overrideStore,
962+
});
963+
client.setAssignmentLogger(mockLogger);
964+
client.useNonExpiringInMemoryAssignmentCache();
965+
});
966+
967+
it('returns override values for all supported types', () => {
968+
overrideStore.setEntries({
969+
'string-flag': {
970+
key: 'override-variation',
971+
value: 'override-string',
972+
},
973+
'boolean-flag': {
974+
key: 'override-variation',
975+
value: true,
976+
},
977+
'numeric-flag': {
978+
key: 'override-variation',
979+
value: 42.5,
980+
},
981+
'json-flag': {
982+
key: 'override-variation',
983+
value: '{"foo": "bar"}',
984+
},
985+
});
986+
987+
expect(client.getStringAssignment('string-flag', 'subject-10', {}, 'default')).toBe(
988+
'override-string',
989+
);
990+
expect(client.getBooleanAssignment('boolean-flag', 'subject-10', {}, false)).toBe(true);
991+
expect(client.getNumericAssignment('numeric-flag', 'subject-10', {}, 0)).toBe(42.5);
992+
expect(client.getJSONAssignment('json-flag', 'subject-10', {}, {})).toEqual({ foo: 'bar' });
993+
});
994+
995+
it('does not log assignments when override is applied', () => {
996+
overrideStore.setEntries({
997+
[flagKey]: {
998+
key: 'override-variation',
999+
value: 'override-value',
1000+
},
1001+
});
1002+
1003+
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1004+
1005+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(0);
1006+
});
1007+
1008+
it('includes override details in assignment details', () => {
1009+
overrideStore.setEntries({
1010+
[flagKey]: {
1011+
key: 'override-variation',
1012+
value: 'override-value',
1013+
},
1014+
});
1015+
1016+
const result = client.getStringAssignmentDetails(
1017+
flagKey,
1018+
'subject-10',
1019+
{ foo: 3 },
1020+
'default',
1021+
);
1022+
1023+
expect(result).toMatchObject({
1024+
variation: 'override-value',
1025+
evaluationDetails: {
1026+
flagEvaluationCode: 'MATCH',
1027+
flagEvaluationDescription: 'Flag override applied',
1028+
},
1029+
});
1030+
});
1031+
1032+
it('does not update assignment cache when override is applied', () => {
1033+
const mockAssignmentCache = td.object<AssignmentCache>();
1034+
td.when(mockAssignmentCache.has(td.matchers.anything())).thenReturn(false);
1035+
td.when(mockAssignmentCache.set(td.matchers.anything())).thenReturn();
1036+
client.useCustomAssignmentCache(mockAssignmentCache);
1037+
1038+
overrideStore.setEntries({
1039+
[flagKey]: {
1040+
key: 'override-variation',
1041+
value: 'override-value',
1042+
},
1043+
});
1044+
1045+
// First call with override
1046+
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1047+
1048+
// Verify cache was not used at all
1049+
expect(td.explain(mockAssignmentCache.set).callCount).toBe(0);
1050+
1051+
// Remove override
1052+
overrideStore.setEntries({});
1053+
1054+
// Second call without override
1055+
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1056+
1057+
// Now cache should be used
1058+
expect(td.explain(mockAssignmentCache.set).callCount).toBe(1);
1059+
});
1060+
1061+
it('uses normal assignment when no override exists for flag', () => {
1062+
// Set override for a different flag
1063+
overrideStore.setEntries({
1064+
'other-flag': {
1065+
key: 'override-variation',
1066+
value: 'override-value',
1067+
},
1068+
});
1069+
1070+
const result = client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1071+
1072+
// Should get the normal assignment value from mockFlag
1073+
expect(result).toBe(variationA.value);
1074+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(1);
1075+
});
1076+
1077+
it('uses normal assignment when no overrides store is configured', () => {
1078+
// Create client without overrides store
1079+
const clientWithoutOverrides = new EppoClient({
1080+
flagConfigurationStore: storage,
1081+
});
1082+
clientWithoutOverrides.setAssignmentLogger(mockLogger);
1083+
1084+
const result = clientWithoutOverrides.getStringAssignment(
1085+
flagKey,
1086+
'subject-10',
1087+
{},
1088+
'default',
1089+
);
1090+
1091+
// Should get the normal assignment value from mockFlag
1092+
expect(result).toBe(variationA.value);
1093+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(1);
1094+
});
1095+
1096+
it('respects override after initial assignment without override', () => {
1097+
// First call without override
1098+
const initialAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1099+
expect(initialAssignment).toBe(variationA.value);
1100+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(1);
1101+
1102+
// Set override and make second call
1103+
overrideStore.setEntries({
1104+
[flagKey]: {
1105+
key: 'override-variation',
1106+
value: 'override-value',
1107+
},
1108+
});
1109+
1110+
const overriddenAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1111+
expect(overriddenAssignment).toBe('override-value');
1112+
// No additional logging should occur when using override
1113+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(1);
1114+
});
1115+
1116+
it('reverts to normal assignment after removing override', () => {
1117+
// Set initial override
1118+
overrideStore.setEntries({
1119+
[flagKey]: {
1120+
key: 'override-variation',
1121+
value: 'override-value',
1122+
},
1123+
});
1124+
1125+
const overriddenAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1126+
expect(overriddenAssignment).toBe('override-value');
1127+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(0);
1128+
1129+
// Remove override and make second call
1130+
overrideStore.setEntries({});
1131+
1132+
const normalAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1133+
expect(normalAssignment).toBe(variationA.value);
1134+
// Should log the normal assignment
1135+
expect(td.explain(mockLogger.logAssignment).callCount).toBe(1);
1136+
});
1137+
1138+
it('reverts to normal assignment after unsetting overrides store', () => {
1139+
overrideStore.setEntries({
1140+
[flagKey]: {
1141+
key: 'override-variation',
1142+
value: 'override-value',
1143+
},
1144+
});
1145+
1146+
client.unsetOverrideStore();
1147+
1148+
const normalAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
1149+
expect(normalAssignment).toBe(variationA.value);
1150+
});
1151+
1152+
it('returns a mapping of flag key to variation key for all active overrides', () => {
1153+
overrideStore.setEntries({
1154+
[flagKey]: {
1155+
key: 'override-variation',
1156+
value: 'override-value',
1157+
},
1158+
'other-flag': {
1159+
key: 'other-variation',
1160+
value: 'other-value',
1161+
},
1162+
});
1163+
1164+
expect(client.getOverrideVariationKeys()).toEqual({
1165+
[flagKey]: 'override-variation',
1166+
'other-flag': 'other-variation',
1167+
});
1168+
});
1169+
});
9481170
});

src/client/eppo-client.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
PrecomputedConfiguration,
2222
} from '../configuration';
2323
import ConfigurationRequestor from '../configuration-requestor';
24-
import { IConfigurationStore } from '../configuration-store/configuration-store';
24+
import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store';
2525
import {
2626
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
2727
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
@@ -30,7 +30,7 @@ import {
3030
} from '../constants';
3131
import { decodeFlag } from '../decoding';
3232
import { EppoValue } from '../eppo_value';
33-
import { Evaluator, FlagEvaluation, noneResult } from '../evaluator';
33+
import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator';
3434
import { BoundedEventQueue } from '../events/bounded-event-queue';
3535
import EventDispatcher from '../events/event-dispatcher';
3636
import NoOpEventDispatcher from '../events/no-op-event-dispatcher';
@@ -116,6 +116,7 @@ export default class EppoClient {
116116
private configurationRequestParameters?: FlagConfigurationRequestParameters;
117117
private banditModelConfigurationStore?: IConfigurationStore<BanditParameters>;
118118
private banditVariationConfigurationStore?: IConfigurationStore<BanditVariation[]>;
119+
private overrideStore?: ISyncStore<Variation>;
119120
private flagConfigurationStore: IConfigurationStore<Flag | ObfuscatedFlag>;
120121
private assignmentLogger?: IAssignmentLogger;
121122
private assignmentCache?: AssignmentCache;
@@ -131,12 +132,24 @@ export default class EppoClient {
131132
flagConfigurationStore,
132133
banditVariationConfigurationStore,
133134
banditModelConfigurationStore,
135+
overrideStore,
134136
configurationRequestParameters,
135-
}: EppoClientParameters) {
137+
}: {
138+
// Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment
139+
// or bandit events). These events are application-specific and captures by EppoClient#track API.
140+
eventDispatcher?: EventDispatcher;
141+
flagConfigurationStore: IConfigurationStore<Flag | ObfuscatedFlag>;
142+
banditVariationConfigurationStore?: IConfigurationStore<BanditVariation[]>;
143+
banditModelConfigurationStore?: IConfigurationStore<BanditParameters>;
144+
overrideStore?: ISyncStore<Variation>;
145+
configurationRequestParameters?: FlagConfigurationRequestParameters;
146+
isObfuscated?: boolean;
147+
}) {
136148
this.eventDispatcher = eventDispatcher;
137149
this.flagConfigurationStore = flagConfigurationStore;
138150
this.banditVariationConfigurationStore = banditVariationConfigurationStore;
139151
this.banditModelConfigurationStore = banditModelConfigurationStore;
152+
this.overrideStore = overrideStore;
140153
this.configurationRequestParameters = configurationRequestParameters;
141154
this.isObfuscated = isObfuscated;
142155
}
@@ -192,6 +205,24 @@ export default class EppoClient {
192205
this.isObfuscated = isObfuscated;
193206
}
194207

208+
setOverrideStore(store: ISyncStore<Variation>): void {
209+
this.overrideStore = store;
210+
}
211+
212+
unsetOverrideStore(): void {
213+
this.overrideStore = undefined;
214+
}
215+
216+
// Returns a mapping of flag key to variation key for all active overrides
217+
getOverrideVariationKeys(): Record<string, string> {
218+
return Object.fromEntries(
219+
Object.entries(this.overrideStore?.entries() ?? {}).map(([flagKey, value]) => [
220+
flagKey,
221+
value.key,
222+
]),
223+
);
224+
}
225+
195226
async fetchFlagConfigurations() {
196227
if (!this.configurationRequestParameters) {
197228
throw new Error(
@@ -940,6 +971,17 @@ export default class EppoClient {
940971
validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank');
941972

942973
const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(flagKey);
974+
const overrideVariation = this.overrideStore?.get(flagKey);
975+
if (overrideVariation) {
976+
return overrideResult(
977+
flagKey,
978+
subjectKey,
979+
subjectAttributes,
980+
overrideVariation,
981+
flagEvaluationDetailsBuilder,
982+
);
983+
}
984+
943985
const configDetails = this.getConfigDetails();
944986
const flag = this.getFlag(flagKey);
945987

0 commit comments

Comments
 (0)