Skip to content

Commit 07c19c8

Browse files
authored
feat: ability to export precomputed assignments (#160)
* precomputed flags and tests * add flag obfuscation *ConfigurationWire * salted hashing
1 parent cfcdb73 commit 07c19c8

10 files changed

+372
-16
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.6.3",
3+
"version": "4.7.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: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import {
1313
validateTestAssignments,
1414
} from '../../test/testHelpers';
1515
import { IAssignmentLogger } from '../assignment-logger';
16+
import { IConfigurationWire } from '../configuration';
1617
import { IConfigurationStore } from '../configuration-store/configuration-store';
1718
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1819
import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
20+
import { decodePrecomputedFlag } from '../decoding';
1921
import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
22+
import { setSaltOverrideForTests } from '../obfuscation';
2023
import { AttributeType } from '../types';
2124

2225
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
@@ -177,6 +180,114 @@ describe('EppoClient E2E test', () => {
177180
});
178181
});
179182

183+
describe('precomputed flags', () => {
184+
beforeAll(() => {
185+
storage.setEntries({
186+
[flagKey]: mockFlag,
187+
disabledFlag: { ...mockFlag, enabled: false },
188+
anotherFlag: {
189+
...mockFlag,
190+
allocations: [
191+
{
192+
key: 'allocation-b',
193+
rules: [],
194+
splits: [
195+
{
196+
shards: [],
197+
variationKey: 'b',
198+
},
199+
],
200+
doLog: true,
201+
},
202+
],
203+
},
204+
});
205+
});
206+
207+
let client: EppoClient;
208+
beforeEach(() => {
209+
client = new EppoClient({ flagConfigurationStore: storage });
210+
});
211+
212+
afterEach(() => {
213+
setSaltOverrideForTests(null);
214+
});
215+
216+
it('skips disabled flags', () => {
217+
const encodedPrecomputedWire = client.getPrecomputedAssignments('subject', {});
218+
const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire;
219+
if (!precomputed) {
220+
fail('Precomputed data not in Configuration response');
221+
}
222+
const precomputedResponse = JSON.parse(precomputed.response);
223+
224+
expect(precomputedResponse).toBeTruthy();
225+
const precomputedFlags = precomputedResponse?.flags ?? {};
226+
expect(Object.keys(precomputedFlags)).toContain('anotherFlag');
227+
expect(Object.keys(precomputedFlags)).toContain(flagKey);
228+
expect(Object.keys(precomputedFlags)).not.toContain('disabledFlag');
229+
});
230+
231+
it('evaluates and returns assignments', () => {
232+
const encodedPrecomputedWire = client.getPrecomputedAssignments('subject', {});
233+
const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire;
234+
if (!precomputed) {
235+
fail('Precomputed data not in Configuration response');
236+
}
237+
const precomputedResponse = JSON.parse(precomputed.response);
238+
239+
expect(precomputedResponse).toBeTruthy();
240+
const precomputedFlags = precomputedResponse?.flags ?? {};
241+
const firstFlag = precomputedFlags[flagKey];
242+
const secondFlag = precomputedFlags['anotherFlag'];
243+
expect(firstFlag.variationValue).toEqual('variation-a');
244+
expect(secondFlag.variationValue).toEqual('variation-b');
245+
});
246+
247+
it('obfuscates assignments', () => {
248+
// Use a known salt to produce deterministic hashes
249+
setSaltOverrideForTests({
250+
base64String: 'BzURTg==',
251+
saltString: '0735114e',
252+
bytes: new Uint8Array([7, 53, 17, 78]),
253+
});
254+
255+
const encodedPrecomputedWire = client.getPrecomputedAssignments('subject', {}, true);
256+
const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire;
257+
if (!precomputed) {
258+
fail('Precomputed data not in Configuration response');
259+
}
260+
const precomputedResponse = JSON.parse(precomputed.response);
261+
262+
expect(precomputedResponse).toBeTruthy();
263+
expect(precomputedResponse.salt).toEqual('BzURTg==');
264+
265+
const precomputedFlags = precomputedResponse?.flags ?? {};
266+
expect(Object.keys(precomputedFlags)).toContain('ddc24ede545855b9bbae82cfec6a83a1'); // flagKey, md5 hashed
267+
expect(Object.keys(precomputedFlags)).toContain('2b439e5a0104d62400dc44c34230f6f2'); // 'anotherFlag', md5 hashed
268+
269+
const decodedFirstFlag = decodePrecomputedFlag(
270+
precomputedFlags['ddc24ede545855b9bbae82cfec6a83a1'],
271+
);
272+
expect(decodedFirstFlag.flagKey).toEqual('ddc24ede545855b9bbae82cfec6a83a1');
273+
expect(decodedFirstFlag.variationType).toEqual(VariationType.STRING);
274+
expect(decodedFirstFlag.variationKey).toEqual('a');
275+
expect(decodedFirstFlag.variationValue).toEqual('variation-a');
276+
expect(decodedFirstFlag.doLog).toEqual(true);
277+
expect(decodedFirstFlag.extraLogging).toEqual({});
278+
279+
const decodedSecondFlag = decodePrecomputedFlag(
280+
precomputedFlags['2b439e5a0104d62400dc44c34230f6f2'],
281+
);
282+
expect(decodedSecondFlag.flagKey).toEqual('2b439e5a0104d62400dc44c34230f6f2');
283+
expect(decodedSecondFlag.variationType).toEqual(VariationType.STRING);
284+
expect(decodedSecondFlag.variationKey).toEqual('b');
285+
expect(decodedSecondFlag.variationValue).toEqual('variation-b');
286+
expect(decodedSecondFlag.doLog).toEqual(true);
287+
expect(decodedSecondFlag.extraLogging).toEqual({});
288+
});
289+
});
290+
180291
describe('UFC Shared Test Cases', () => {
181292
const testCases = testCasesByFileName<IAssignmentTestCase>(ASSIGNMENT_TEST_DATA_DIR);
182293

src/client/eppo-client.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import { AssignmentCache } from '../cache/abstract-assignment-cache';
99
import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache';
1010
import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment';
1111
import { TLRUInMemoryAssignmentCache } from '../cache/tlru-in-memory-assignment-cache';
12+
import {
13+
IConfigurationWire,
14+
ConfigurationWireV1,
15+
IPrecomputedConfiguration,
16+
ObfuscatedPrecomputedConfiguration,
17+
PrecomputedConfiguration,
18+
} from '../configuration';
1219
import ConfigurationRequestor from '../configuration-requestor';
1320
import { IConfigurationStore } from '../configuration-store/configuration-store';
1421
import {
@@ -35,6 +42,7 @@ import {
3542
ConfigDetails,
3643
Flag,
3744
ObfuscatedFlag,
45+
PrecomputedFlag,
3846
Variation,
3947
VariationType,
4048
} from '../interfaces';
@@ -876,6 +884,85 @@ export default class EppoClient {
876884
throw err;
877885
}
878886

887+
private getAllAssignments(
888+
subjectKey: string,
889+
subjectAttributes: Attributes = {},
890+
): Record<string, PrecomputedFlag> {
891+
const configDetails = this.getConfigDetails();
892+
const flagKeys = this.getFlagKeys();
893+
const flags: Record<string, PrecomputedFlag> = {};
894+
895+
// Evaluate all the enabled flags for the user
896+
flagKeys.forEach((flagKey) => {
897+
const flag = this.getFlag(flagKey);
898+
if (!flag) {
899+
logger.debug(`[Eppo SDK] No assigned variation. Flag does not exist.`);
900+
return;
901+
}
902+
903+
// Evaluate the flag for this subject.
904+
const evaluation = this.evaluator.evaluateFlag(
905+
flag,
906+
configDetails,
907+
subjectKey,
908+
subjectAttributes,
909+
this.isObfuscated,
910+
);
911+
912+
// allocationKey is set along with variation when there is a result. this check appeases typescript below
913+
if (!evaluation.variation || !evaluation.allocationKey) {
914+
logger.debug(`[Eppo SDK] No assigned variation: ${flagKey}`);
915+
return;
916+
}
917+
918+
// Transform into a PrecomputedFlag
919+
flags[flagKey] = {
920+
flagKey,
921+
allocationKey: evaluation.allocationKey,
922+
doLog: evaluation.doLog,
923+
extraLogging: evaluation.extraLogging,
924+
variationKey: evaluation.variation.key,
925+
variationType: flag.variationType,
926+
variationValue: evaluation.variation.value.toString(),
927+
};
928+
});
929+
930+
return flags;
931+
}
932+
933+
/**
934+
* Computes and returns assignments for a subject from all loaded flags.
935+
*
936+
* @param subjectKey an identifier of the experiment subject, for example a user ID.
937+
* @param subjectAttributes optional attributes associated with the subject, for example name and email.
938+
* @param obfuscated optional whether to obfuscate the results.
939+
*/
940+
getPrecomputedAssignments(
941+
subjectKey: string,
942+
subjectAttributes: Attributes = {},
943+
obfuscated = false,
944+
): string {
945+
const configDetails = this.getConfigDetails();
946+
const flags = this.getAllAssignments(subjectKey, subjectAttributes);
947+
948+
const precomputedConfig: IPrecomputedConfiguration = obfuscated
949+
? new ObfuscatedPrecomputedConfiguration(
950+
subjectKey,
951+
flags,
952+
subjectAttributes,
953+
configDetails.configEnvironment,
954+
)
955+
: new PrecomputedConfiguration(
956+
subjectKey,
957+
flags,
958+
subjectAttributes,
959+
configDetails.configEnvironment,
960+
);
961+
962+
const configWire: IConfigurationWire = new ConfigurationWireV1(precomputedConfig);
963+
return JSON.stringify(configWire);
964+
}
965+
879966
/**
880967
* [Experimental] Get a detailed return of assignment for a particular subject and flag.
881968
*

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe('EppoPrecomputedClient E2E test', () => {
9292

9393
const precomputedFlagKey = 'mock-flag';
9494
const mockPrecomputedFlag: PrecomputedFlag = {
95+
flagKey: precomputedFlagKey,
9596
variationKey: 'a',
9697
variationValue: 'variation-a',
9798
allocationKey: 'allocation-a',
@@ -736,6 +737,7 @@ describe('EppoPrecomputedClient E2E test', () => {
736737

737738
await store.setEntries({
738739
'test-flag': {
740+
flagKey: precomputedFlagKey,
739741
variationType: VariationType.STRING,
740742
variationKey: 'control',
741743
variationValue: 'test-value',

src/configuration.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Environment, FormatEnum, PrecomputedFlag } from './interfaces';
2+
import { generateSalt, obfuscatePrecomputedFlags, Salt } from './obfuscation';
3+
import { Attributes, ContextAttributes } from './types';
4+
5+
export interface IPrecomputedConfigurationResponse {
6+
// `format` is always `PRECOMPUTED`
7+
readonly format: FormatEnum;
8+
readonly obfuscated: boolean;
9+
// Salt used for hashing md5-encoded strings.
10+
readonly salt: string;
11+
readonly createdAt: string;
12+
// Environment might be missing if configuration was absent during evaluation.
13+
readonly environment?: Environment;
14+
readonly flags: Record<string, PrecomputedFlag>;
15+
}
16+
17+
export interface IPrecomputedConfiguration {
18+
// JSON encoded `PrecomputedConfigurationResponse`
19+
readonly response: string;
20+
readonly subjectKey: string;
21+
// Optional in case server does not want to expose attributes to a client.
22+
readonly subjectAttributes?: Attributes | ContextAttributes;
23+
}
24+
25+
export class PrecomputedConfiguration implements IPrecomputedConfiguration {
26+
readonly format = FormatEnum.PRECOMPUTED;
27+
readonly response: string;
28+
29+
constructor(
30+
readonly subjectKey: string,
31+
flags: Record<string, PrecomputedFlag>,
32+
readonly subjectAttributes?: Attributes | ContextAttributes,
33+
environment?: Environment,
34+
) {
35+
const precomputedResponse: IPrecomputedConfigurationResponse = {
36+
format: FormatEnum.PRECOMPUTED,
37+
obfuscated: false,
38+
salt: '',
39+
createdAt: new Date().toISOString(),
40+
environment,
41+
flags,
42+
};
43+
this.response = JSON.stringify(precomputedResponse);
44+
}
45+
}
46+
47+
export class ObfuscatedPrecomputedConfiguration implements IPrecomputedConfiguration {
48+
readonly format = FormatEnum.PRECOMPUTED;
49+
readonly response: string;
50+
private saltBase: Salt;
51+
52+
constructor(
53+
readonly subjectKey: string,
54+
flags: Record<string, PrecomputedFlag>,
55+
readonly subjectAttributes?: Attributes | ContextAttributes,
56+
environment?: Environment,
57+
) {
58+
this.saltBase = generateSalt();
59+
60+
const precomputedResponse: IPrecomputedConfigurationResponse = {
61+
format: FormatEnum.PRECOMPUTED,
62+
obfuscated: true,
63+
salt: this.saltBase.base64String,
64+
createdAt: new Date().toISOString(),
65+
environment,
66+
flags: obfuscatePrecomputedFlags(this.saltBase.saltString, flags),
67+
};
68+
this.response = JSON.stringify(precomputedResponse);
69+
}
70+
}
71+
72+
// "Wire" in the name means "in-transit"/"file" format.
73+
// In-memory representation may differ significantly and is up to SDKs.
74+
export interface IConfigurationWire {
75+
/**
76+
* Version field should be incremented for breaking format changes.
77+
* For example, removing required fields or changing field type/meaning.
78+
*/
79+
readonly version: number;
80+
81+
// TODO: Add flags and bandits for offline/non-precomputed initialization
82+
readonly precomputed?: IPrecomputedConfiguration;
83+
}
84+
85+
export class ConfigurationWireV1 implements IConfigurationWire {
86+
version = 1;
87+
constructor(readonly precomputed?: IPrecomputedConfiguration) {}
88+
}

src/http-client.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import ApiEndpoints from './api-endpoints';
2+
import { IPrecomputedConfigurationResponse } from './configuration';
23
import {
34
BanditParameters,
45
BanditVariation,
56
Environment,
67
Flag,
78
FormatEnum,
8-
PrecomputedFlag,
99
PrecomputedFlagsPayload,
1010
} from './interfaces';
1111
import { Attributes } from './types';
@@ -42,19 +42,12 @@ export interface IBanditParametersResponse {
4242
bandits: Record<string, BanditParameters>;
4343
}
4444

45-
export interface IPrecomputedFlagsResponse {
46-
createdAt: string;
47-
format: FormatEnum;
48-
environment: Environment;
49-
flags: Record<string, PrecomputedFlag>;
50-
}
51-
5245
export interface IHttpClient {
5346
getUniversalFlagConfiguration(): Promise<IUniversalFlagConfigResponse | undefined>;
5447
getBanditParameters(): Promise<IBanditParametersResponse | undefined>;
5548
getPrecomputedFlags(
5649
payload: PrecomputedFlagsPayload,
57-
): Promise<IPrecomputedFlagsResponse | undefined>;
50+
): Promise<IPrecomputedConfigurationResponse | undefined>;
5851
rawGet<T>(url: URL): Promise<T | undefined>;
5952
rawPost<T, P>(url: URL, payload: P): Promise<T | undefined>;
6053
}
@@ -74,9 +67,12 @@ export default class FetchHttpClient implements IHttpClient {
7467

7568
async getPrecomputedFlags(
7669
payload: PrecomputedFlagsPayload,
77-
): Promise<IPrecomputedFlagsResponse | undefined> {
70+
): Promise<IPrecomputedConfigurationResponse | undefined> {
7871
const url = this.apiEndpoints.precomputedFlagsEndpoint();
79-
return await this.rawPost<IPrecomputedFlagsResponse, PrecomputedFlagsPayload>(url, payload);
72+
return await this.rawPost<IPrecomputedConfigurationResponse, PrecomputedFlagsPayload>(
73+
url,
74+
payload,
75+
);
8076
}
8177

8278
async rawGet<T>(url: URL): Promise<T | undefined> {

0 commit comments

Comments
 (0)