Skip to content
105 changes: 102 additions & 3 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ import {

import * as util from './util/index';

import { getInstance, IAssignmentEvent, IAssignmentLogger, init, NO_OP_EVENT_DISPATCHER } from '.';
import {
getFlagsConfiguration,
getInstance,
IAssignmentEvent,
IAssignmentLogger,
init,
NO_OP_EVENT_DISPATCHER,
} from '.';

import SpyInstance = jest.SpyInstance;

Expand Down Expand Up @@ -501,6 +508,10 @@ describe('EppoClient E2E test', () => {
},
};

afterEach(() => {
td.reset();
});

it('retries initial configuration request before resolving', async () => {
td.replace(HttpClient.prototype, 'getUniversalFlagConfiguration');
let callCount = 0;
Expand Down Expand Up @@ -635,8 +646,6 @@ describe('EppoClient E2E test', () => {
let isReadOnlyFsSpy: SpyInstance;

beforeEach(() => {
// Reset the module before each test
jest.resetModules();
// Create a spy on isReadOnlyFs that we can mock
isReadOnlyFsSpy = jest.spyOn(util, 'isReadOnlyFs');
});
Expand Down Expand Up @@ -735,4 +744,94 @@ describe('EppoClient E2E test', () => {
expect(configurationRequestParameters.pollAfterSuccessfulInitialization).toBe(false);
});
});

describe('getFlagsConfiguration', () => {
let client: EppoClient | null = null;

afterAll(() => {
if (client) {
client.stopPolling();
}
});

it('returns configuration JSON matching flags-v1.json structure', async () => {
client = await init({
apiKey: 'dummy',
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
assignmentLogger: { logAssignment: jest.fn() },
});

const exportedConfig = getFlagsConfiguration();
expect(exportedConfig).not.toBeNull();

const parsed = JSON.parse(exportedConfig ?? '');

// Verify top-level metadata
expect(parsed.format).toBe('SERVER');
expect(parsed.createdAt).toBe('2024-04-17T19:40:53.716Z');
expect(parsed.environment).toEqual({ name: 'Test' });

// Verify exact number of flags from flags-v1.json
expect(Object.keys(parsed.flags).length).toBe(22);

// Verify a complex flag with rules and conditions: new-user-onboarding
const flag = parsed.flags['new-user-onboarding'];
expect(flag).toBeDefined();
expect(flag.key).toBe('new-user-onboarding');
expect(flag.enabled).toBe(true);
expect(flag.variationType).toBe('STRING');
expect(flag.totalShards).toBe(10000);

// Verify variations
expect(Object.keys(flag.variations).length).toBe(6);
expect(flag.variations.control).toEqual({ key: 'control', value: 'control' });
expect(flag.variations.red).toEqual({ key: 'red', value: 'red' });
expect(flag.variations.blue).toEqual({ key: 'blue', value: 'blue' });
expect(flag.variations.green).toEqual({ key: 'green', value: 'green' });
expect(flag.variations.yellow).toEqual({ key: 'yellow', value: 'yellow' });
expect(flag.variations.purple).toEqual({ key: 'purple', value: 'purple' });

// Verify allocations structure
expect(flag.allocations.length).toBe(4);

// First allocation: "id rule" with MATCHES condition
const idRuleAlloc = flag.allocations[0];
expect(idRuleAlloc.key).toBe('id rule');
expect(idRuleAlloc.doLog).toBe(false);
expect(idRuleAlloc.rules.length).toBe(1);
expect(idRuleAlloc.rules[0].conditions.length).toBe(1);
expect(idRuleAlloc.rules[0].conditions[0]).toEqual({
attribute: 'id',
operator: 'MATCHES',
value: 'zach',
});
expect(idRuleAlloc.splits[0].variationKey).toBe('purple');

// Second allocation: "internal users" with MATCHES condition
const internalUsersAlloc = flag.allocations[1];
expect(internalUsersAlloc.key).toBe('internal users');
expect(internalUsersAlloc.rules[0].conditions[0]).toEqual({
attribute: 'email',
operator: 'MATCHES',
value: '@mycompany.com',
});

// Third allocation: "experiment" with NOT_ONE_OF condition and shards
const experimentAlloc = flag.allocations[2];
expect(experimentAlloc.key).toBe('experiment');
expect(experimentAlloc.doLog).toBe(true);
expect(experimentAlloc.rules[0].conditions[0].operator).toBe('NOT_ONE_OF');
expect(experimentAlloc.rules[0].conditions[0].value).toEqual(['US', 'Canada', 'Mexico']);
expect(experimentAlloc.splits.length).toBe(3); // control, red, yellow

// Fourth allocation: "rollout" with ONE_OF condition and extraLogging
const rolloutAlloc = flag.allocations[3];
expect(rolloutAlloc.key).toBe('rollout');
expect(rolloutAlloc.rules[0].conditions[0].operator).toBe('ONE_OF');
expect(rolloutAlloc.splits[0].extraLogging).toEqual({
allocationvalue_type: 'rollout',
owner: 'hippo',
});
});
});
});
108 changes: 105 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import {
BanditVariation,
BoundedEventQueue,
ContextAttributes,
Environment,
EppoClient,
Event,
EventDispatcher,
Flag,
FlagConfigurationRequestParameters,
FlagKey,
FormatEnum,
MemoryOnlyConfigurationStore,
NamedEventQueue,
applicationLogger,
Expand Down Expand Up @@ -41,6 +43,41 @@ export { IClientConfig };

let clientInstance: EppoClient;

// We keep references to the configuration stores at module level because EppoClient
// does not expose public getters for store metadata (format, createdAt, environment)
// or bandit configurations. These references are needed by getFlagsConfiguration()
// and getBanditsConfiguration() to reconstruct exportable configuration JSON.
let flagConfigurationStore: MemoryOnlyConfigurationStore<Flag>;
let banditVariationConfigurationStore: MemoryOnlyConfigurationStore<BanditVariation[]>;
let banditModelConfigurationStore: MemoryOnlyConfigurationStore<BanditParameters>;
Comment on lines +46 to +52
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying not to mess with the core SDK for now, but future us can expose these which would tidy this up a bit.


/**
* Represents a bandit reference linking a bandit to its flag variations.
*
* TODO: Remove this local definition once BanditReference is exported from @eppo/js-client-sdk-common.
* This duplicates the BanditReference interface from the common package's http-client module,
* which is not currently exported from the package's public API.
*/
interface BanditReference {
modelVersion: string;
flagVariations: BanditVariation[];
}

/**
* Represents the universal flag configuration response format.
*
* TODO: Remove this local definition once IUniversalFlagConfigResponse is exported from @eppo/js-client-sdk-common.
* This duplicates the IUniversalFlagConfigResponse interface from the common package's http-client module,
* which is not currently exported from the package's public API.
*/
interface FlagsConfigurationResponse {
createdAt: string;
format: FormatEnum;
environment: Environment;
flags: Record<string, Flag>;
banditReferences: Record<string, BanditReference>;
}
Comment on lines +54 to +79
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greghuels it turns out the types from common repo used by the HttpClient were not exported. For containing change footprint opting to reproduce these here and then in a later effort can export and use them here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good!


export const NO_OP_EVENT_DISPATCHER: EventDispatcher = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
attachContext: () => {},
Expand Down Expand Up @@ -86,9 +123,9 @@ export async function init(config: IClientConfig): Promise<EppoClient> {
throwOnFailedInitialization,
};

const flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
const banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
const eventDispatcher = newEventDispatcher(apiKey, eventTracking);

clientInstance = new EppoClient({
Expand Down Expand Up @@ -144,6 +181,71 @@ export function getInstance(): EppoClient {
return clientInstance;
}

/**
* Reconstructs the current flags configuration as a JSON string.
* This can be used to bootstrap another SDK instance using offlineInit().
*
* @returns JSON string containing the flags configuration, or null if not initialized
* @public
*/
export function getFlagsConfiguration(): string | null {
if (!flagConfigurationStore) {
return null;
}

// Build configuration matching FlagsConfigurationResponse structure.
// All fields are required - they are guaranteed to exist after successful initialization.
const configuration: FlagsConfigurationResponse = {
createdAt: flagConfigurationStore.getConfigPublishedAt() ?? new Date().toISOString(),
format: flagConfigurationStore.getFormat() ?? FormatEnum.SERVER,
environment: flagConfigurationStore.getEnvironment() ?? { name: 'UNKNOWN' },
flags: flagConfigurationStore.entries(),
banditReferences: reconstructBanditReferences(),
};

return JSON.stringify(configuration);
}

/**
* Reconstructs banditReferences from stored variations and parameters.
* The variations are stored indexed by flag key, so we need to re-pivot them
* back to being indexed by bandit key for export.
*/
function reconstructBanditReferences(): Record<string, BanditReference> {
if (!banditVariationConfigurationStore || !banditModelConfigurationStore) {
return {};
}

const variationsByFlagKey = banditVariationConfigurationStore.entries();
const banditParameters = banditModelConfigurationStore.entries();

// Flatten all variations and group by bandit key
const variationsByBanditKey: Record<string, BanditVariation[]> = {};
for (const variations of Object.values(variationsByFlagKey)) {
for (const variation of variations) {
const banditKey = variation.key;
if (!variationsByBanditKey[banditKey]) {
variationsByBanditKey[banditKey] = [];
}
variationsByBanditKey[banditKey].push(variation);
}
}

// Build banditReferences with model versions
const banditReferences: Record<string, BanditReference> = {};
for (const [banditKey, variations] of Object.entries(variationsByBanditKey)) {
const params = banditParameters[banditKey];
if (params) {
banditReferences[banditKey] = {
modelVersion: params.modelVersion,
flagVariations: variations,
};
}
}

return banditReferences;
}

function newEventDispatcher(
sdkKey: string,
config: IClientConfig['eventTracking'] = {},
Expand Down