Skip to content

Commit ffa30b4

Browse files
authored
feat: inspect format field to determine whether config is obfuscated (#218)
* chore: inspect field to determine whether config is obfuscated * update test mocking * cache obfuscated checks and await in tests * chore: drop string to Enum and lean into string enum shape goodness
1 parent 32ae00a commit ffa30b4

File tree

3 files changed

+102
-37
lines changed

3 files changed

+102
-37
lines changed

src/client/eppo-client.spec.ts

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import * as td from 'testdouble';
44

55
import {
66
ASSIGNMENT_TEST_DATA_DIR,
7+
getTestAssignments,
78
IAssignmentTestCase,
89
MOCK_UFC_RESPONSE_FILE,
910
OBFUSCATED_MOCK_UFC_RESPONSE_FILE,
10-
SubjectTestCase,
11-
getTestAssignments,
1211
readMockUFCResponse,
12+
SubjectTestCase,
1313
testCasesByFileName,
1414
validateTestAssignments,
1515
} from '../../test/testHelpers';
@@ -22,13 +22,13 @@ import {
2222
} from '../configuration';
2323
import { IConfigurationStore } from '../configuration-store/configuration-store';
2424
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
25-
import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
25+
import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants';
2626
import { decodePrecomputedFlag } from '../decoding';
27-
import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces';
27+
import { Flag, ObfuscatedFlag, VariationType, FormatEnum, Variation } from '../interfaces';
2828
import { getMD5Hash } from '../obfuscation';
2929
import { AttributeType } from '../types';
3030

31-
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
31+
import EppoClient, { checkTypeMatch, FlagConfigurationRequestParameters } from './eppo-client';
3232
import { initConfiguration } from './test-utils';
3333

3434
// Use a known salt to produce deterministic hashes
@@ -46,6 +46,18 @@ describe('EppoClient E2E test', () => {
4646
}) as jest.Mock;
4747
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
4848

49+
/**
50+
* Use this helper instead of directly setting entries on the `storage` ConfigurationStore.
51+
* This method ensures the format field is set as it is required for parsing.
52+
* @param entries
53+
*/
54+
function setUnobfuscatedFlagEntries(
55+
entries: Record<string, Flag | ObfuscatedFlag>,
56+
): Promise<boolean> {
57+
storage.setFormat(FormatEnum.SERVER);
58+
return storage.setEntries(entries);
59+
}
60+
4961
beforeAll(async () => {
5062
await initConfiguration(storage);
5163
});
@@ -88,8 +100,8 @@ describe('EppoClient E2E test', () => {
88100
describe('error encountered', () => {
89101
let client: EppoClient;
90102

91-
beforeAll(() => {
92-
storage.setEntries({ [flagKey]: mockFlag });
103+
beforeAll(async () => {
104+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
93105
client = new EppoClient({ flagConfigurationStore: storage });
94106

95107
td.replace(EppoClient.prototype, 'getAssignmentDetail', function () {
@@ -144,8 +156,8 @@ describe('EppoClient E2E test', () => {
144156
});
145157

146158
describe('setLogger', () => {
147-
beforeAll(() => {
148-
storage.setEntries({ [flagKey]: mockFlag });
159+
beforeAll(async () => {
160+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
149161
});
150162

151163
it('Invokes logger for queued events', () => {
@@ -192,8 +204,8 @@ describe('EppoClient E2E test', () => {
192204
});
193205

194206
describe('precomputed flags', () => {
195-
beforeAll(() => {
196-
storage.setEntries({
207+
beforeAll(async () => {
208+
await setUnobfuscatedFlagEntries({
197209
[flagKey]: mockFlag,
198210
disabledFlag: { ...mockFlag, enabled: false },
199211
anotherFlag: {
@@ -425,10 +437,10 @@ describe('EppoClient E2E test', () => {
425437
);
426438
});
427439

428-
it('logs variation assignment and experiment key', () => {
440+
it('logs variation assignment and experiment key', async () => {
429441
const mockLogger = td.object<IAssignmentLogger>();
430442

431-
storage.setEntries({ [flagKey]: mockFlag });
443+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
432444
const client = new EppoClient({ flagConfigurationStore: storage });
433445
client.setAssignmentLogger(mockLogger);
434446

@@ -450,11 +462,11 @@ describe('EppoClient E2E test', () => {
450462
expect(loggedAssignmentEvent.allocation).toEqual(mockFlag.allocations[0].key);
451463
});
452464

453-
it('handles logging exception', () => {
465+
it('handles logging exception', async () => {
454466
const mockLogger = td.object<IAssignmentLogger>();
455467
td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error'));
456468

457-
storage.setEntries({ [flagKey]: mockFlag });
469+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
458470
const client = new EppoClient({ flagConfigurationStore: storage });
459471
client.setAssignmentLogger(mockLogger);
460472

@@ -469,8 +481,8 @@ describe('EppoClient E2E test', () => {
469481
expect(assignment).toEqual('variation-a');
470482
});
471483

472-
it('exports flag configuration', () => {
473-
storage.setEntries({ [flagKey]: mockFlag });
484+
it('exports flag configuration', async () => {
485+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
474486
const client = new EppoClient({ flagConfigurationStore: storage });
475487
expect(client.getFlagConfigurations()).toEqual({ [flagKey]: mockFlag });
476488
});
@@ -479,10 +491,10 @@ describe('EppoClient E2E test', () => {
479491
let client: EppoClient;
480492
let mockLogger: IAssignmentLogger;
481493

482-
beforeEach(() => {
494+
beforeEach(async () => {
483495
mockLogger = td.object<IAssignmentLogger>();
484496

485-
storage.setEntries({ [flagKey]: mockFlag });
497+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
486498
client = new EppoClient({ flagConfigurationStore: storage });
487499
client.setAssignmentLogger(mockLogger);
488500
});
@@ -537,7 +549,7 @@ describe('EppoClient E2E test', () => {
537549
});
538550

539551
it('logs for each unique flag', async () => {
540-
await storage.setEntries({
552+
await setUnobfuscatedFlagEntries({
541553
[flagKey]: mockFlag,
542554
'flag-2': {
543555
...mockFlag,
@@ -564,10 +576,10 @@ describe('EppoClient E2E test', () => {
564576
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(3);
565577
});
566578

567-
it('logs twice for the same flag when allocations change', () => {
579+
it('logs twice for the same flag when allocations change', async () => {
568580
client.useNonExpiringInMemoryAssignmentCache();
569581

570-
storage.setEntries({
582+
await setUnobfuscatedFlagEntries({
571583
[flagKey]: {
572584
...mockFlag,
573585

@@ -588,7 +600,7 @@ describe('EppoClient E2E test', () => {
588600
});
589601
client.getStringAssignment(flagKey, 'subject-10', {}, 'default');
590602

591-
storage.setEntries({
603+
await setUnobfuscatedFlagEntries({
592604
[flagKey]: {
593605
...mockFlag,
594606
allocations: [
@@ -610,17 +622,17 @@ describe('EppoClient E2E test', () => {
610622
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(2);
611623
});
612624

613-
it('logs the same subject/flag/variation after two changes', () => {
625+
it('logs the same subject/flag/variation after two changes', async () => {
614626
client.useNonExpiringInMemoryAssignmentCache();
615627

616628
// original configuration version
617-
storage.setEntries({ [flagKey]: mockFlag });
629+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
618630

619631
client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // log this assignment
620632
client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log
621633

622634
// change the variation
623-
storage.setEntries({
635+
await setUnobfuscatedFlagEntries({
624636
[flagKey]: {
625637
...mockFlag,
626638
allocations: [
@@ -643,13 +655,13 @@ describe('EppoClient E2E test', () => {
643655
client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log
644656

645657
// change the flag again, back to the original
646-
storage.setEntries({ [flagKey]: mockFlag });
658+
await setUnobfuscatedFlagEntries({ [flagKey]: mockFlag });
647659

648660
client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // important: log this assignment
649661
client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); // cache hit, don't log
650662

651663
// change the allocation
652-
storage.setEntries({
664+
await setUnobfuscatedFlagEntries({
653665
[flagKey]: {
654666
...mockFlag,
655667
allocations: [

src/client/eppo-client.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-ca
1515
import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment';
1616
import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache';
1717
import {
18-
IConfigurationWire,
1918
ConfigurationWireV1,
19+
IConfigurationWire,
2020
IPrecomputedConfiguration,
2121
PrecomputedConfiguration,
2222
} from '../configuration';
@@ -27,6 +27,7 @@ import {
2727
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
2828
DEFAULT_POLL_INTERVAL_MS,
2929
DEFAULT_REQUEST_TIMEOUT_MS,
30+
OBFUSCATED_FORMATS,
3031
} from '../constants';
3132
import { decodeFlag } from '../decoding';
3233
import { EppoValue } from '../eppo_value';
@@ -46,6 +47,7 @@ import {
4647
BanditVariation,
4748
ConfigDetails,
4849
Flag,
50+
FormatEnum,
4951
IPrecomputedBandit,
5052
ObfuscatedFlag,
5153
PrecomputedFlag,
@@ -101,6 +103,12 @@ export type EppoClientParameters = {
101103
banditVariationConfigurationStore?: IConfigurationStore<BanditVariation[]>;
102104
banditModelConfigurationStore?: IConfigurationStore<BanditParameters>;
103105
configurationRequestParameters?: FlagConfigurationRequestParameters;
106+
/**
107+
* Setting this value will have no side effects other than triggering a warning when the actual
108+
* configuration's obfuscated does not match the value set here.
109+
*
110+
* @deprecated obfuscation is determined by inspecting the `format` field of the UFC response.
111+
*/
104112
isObfuscated?: boolean;
105113
};
106114

@@ -122,7 +130,9 @@ export default class EppoClient {
122130
private assignmentCache?: AssignmentCache;
123131
// whether to suppress any errors and return default values instead
124132
private isGracefulFailureMode = true;
125-
private isObfuscated: boolean;
133+
private expectObfuscated: boolean;
134+
private obfuscationMismatchWarningIssued = false;
135+
private configObfuscatedCache?: boolean;
126136
private requestPoller?: IPoller;
127137
private readonly evaluator = new Evaluator();
128138

@@ -151,7 +161,30 @@ export default class EppoClient {
151161
this.banditModelConfigurationStore = banditModelConfigurationStore;
152162
this.overrideStore = overrideStore;
153163
this.configurationRequestParameters = configurationRequestParameters;
154-
this.isObfuscated = isObfuscated;
164+
this.expectObfuscated = isObfuscated;
165+
}
166+
167+
private maybeWarnAboutObfuscationMismatch(configObfuscated: boolean) {
168+
// Don't warn again if we did on the last check.
169+
if (configObfuscated !== this.expectObfuscated && !this.obfuscationMismatchWarningIssued) {
170+
this.obfuscationMismatchWarningIssued = true;
171+
logger.warn(
172+
`[Eppo SDK] configuration obfuscation [${configObfuscated}] does not match expected [${this.expectObfuscated}]`,
173+
);
174+
} else if (configObfuscated === this.expectObfuscated) {
175+
// Reset the warning to false in case the client configuration (re-)enters a mismatched state.
176+
this.obfuscationMismatchWarningIssued = false;
177+
}
178+
}
179+
180+
private isObfuscated() {
181+
if (this.configObfuscatedCache === undefined) {
182+
this.configObfuscatedCache = OBFUSCATED_FORMATS.includes(
183+
this.flagConfigurationStore.getFormat() ?? FormatEnum.SERVER,
184+
);
185+
}
186+
this.maybeWarnAboutObfuscationMismatch(this.configObfuscatedCache);
187+
return this.configObfuscatedCache;
155188
}
156189

157190
setConfigurationRequestParameters(
@@ -163,6 +196,7 @@ export default class EppoClient {
163196
// noinspection JSUnusedGlobalSymbols
164197
setFlagConfigurationStore(flagConfigurationStore: IConfigurationStore<Flag | ObfuscatedFlag>) {
165198
this.flagConfigurationStore = flagConfigurationStore;
199+
this.configObfuscatedCache = undefined;
166200
}
167201

168202
// noinspection JSUnusedGlobalSymbols
@@ -201,8 +235,15 @@ export default class EppoClient {
201235
}
202236

203237
// noinspection JSUnusedGlobalSymbols
238+
/**
239+
* Setting this value will have no side effects other than triggering a warning when the actual
240+
* configuration's obfuscated does not match the value set here.
241+
*
242+
* @deprecated The client determines whether the configuration is obfuscated by inspection
243+
* @param isObfuscated
244+
*/
204245
setIsObfuscated(isObfuscated: boolean) {
205-
this.isObfuscated = isObfuscated;
246+
this.expectObfuscated = isObfuscated;
206247
}
207248

208249
setOverrideStore(store: ISyncStore<Variation>): void {
@@ -267,6 +308,7 @@ export default class EppoClient {
267308

268309
const pollingCallback = async () => {
269310
if (await this.flagConfigurationStore.isExpired()) {
311+
this.configObfuscatedCache = undefined;
270312
return configurationRequestor.fetchAndStoreConfigurations();
271313
}
272314
};
@@ -885,7 +927,7 @@ export default class EppoClient {
885927
configDetails,
886928
subjectKey,
887929
subjectAttributes,
888-
this.isObfuscated,
930+
this.isObfuscated(),
889931
);
890932

891933
// allocationKey is set along with variation when there is a result. this check appeases typescript below
@@ -1035,15 +1077,16 @@ export default class EppoClient {
10351077
);
10361078
}
10371079

1080+
const isObfuscated = this.isObfuscated();
10381081
const result = this.evaluator.evaluateFlag(
10391082
flag,
10401083
configDetails,
10411084
subjectKey,
10421085
subjectAttributes,
1043-
this.isObfuscated,
1086+
isObfuscated,
10441087
expectedVariationType,
10451088
);
1046-
if (this.isObfuscated) {
1089+
if (isObfuscated) {
10471090
// flag.key is obfuscated, replace with requested flag key
10481091
result.flagKey = flagKey;
10491092
}
@@ -1094,7 +1137,7 @@ export default class EppoClient {
10941137
}
10951138

10961139
private getFlag(flagKey: string): Flag | null {
1097-
return this.isObfuscated
1140+
return this.isObfuscated()
10981141
? this.getObfuscatedFlag(flagKey)
10991142
: this.flagConfigurationStore.get(flagKey);
11001143
}
@@ -1262,7 +1305,7 @@ export default class EppoClient {
12621305

12631306
private buildLoggerMetadata(): Record<string, unknown> {
12641307
return {
1265-
obfuscated: this.isObfuscated,
1308+
obfuscated: this.isObfuscated(),
12661309
sdkLanguage: 'javascript',
12671310
sdkLibVersion: LIB_VERSION,
12681311
};

src/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { FormatEnum } from './interfaces';
2+
13
export const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
24
export const REQUEST_TIMEOUT_MILLIS = DEFAULT_REQUEST_TIMEOUT_MS; // for backwards compatibility
35
export const DEFAULT_POLL_INTERVAL_MS = 30000;
@@ -15,3 +17,11 @@ export const NULL_SENTINEL = 'EPPO_NULL';
1517
export const MAX_EVENT_QUEUE_SIZE = 100;
1618
export const BANDIT_ASSIGNMENT_SHARDS = 10000;
1719
export const DEFAULT_TLRU_TTL_MS = 600_000;
20+
21+
/**
22+
* UFC Configuration formats which are obfuscated.
23+
*
24+
* We use string[] instead of FormatEnum[] to allow easy interaction with this value in its wire type (string).
25+
* Converting from string to enum requires a map lookup or array iteration and is much more awkward than the inverse.
26+
*/
27+
export const OBFUSCATED_FORMATS: string[] = [FormatEnum.CLIENT, FormatEnum.PRECOMPUTED];

0 commit comments

Comments
 (0)