Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create offlinePrecomputedInit #117

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ecf184d
Create offlinePrecomputedInit method
sameerank Dec 5, 2024
80340ca
Adjust config init format to have a precompute object
sameerank Dec 9, 2024
ce4bf21
v3.9.0
sameerank Dec 9, 2024
3fdd774
Bump minor version
sameerank Dec 9, 2024
2cbe3e5
Test for offlinePrecomputedInit
sameerank Dec 9, 2024
acbafaa
Fix tests
sameerank Dec 9, 2024
e3f35a5
Update common version
sameerank Dec 10, 2024
b0f73a7
Docs update
sameerank Dec 10, 2024
723728b
Merge branch 'main' into sameeran/ff-3649-offline-init-method-for-pre…
sameerank Dec 11, 2024
90d725e
Update offline precomputed init to use the configuration wire format
sameerank Dec 11, 2024
c8c6a55
Remove unused IPrecompute import
sameerank Dec 11, 2024
9216ca7
Add a todo comment
sameerank Dec 11, 2024
8b3f8c4
Delete ds store
sameerank Dec 11, 2024
409019a
Merge branch 'main' into sameeran/ff-3649-offline-init-method-for-pre…
sameerank Dec 16, 2024
c625655
Update paths to test files
sameerank Dec 16, 2024
82ca8ba
Change test data branch back to main
sameerank Dec 16, 2024
265bdd9
Fix tests
sameerank Dec 16, 2024
6f32a79
Change test data branch back to main
sameerank Dec 16, 2024
9b4e404
v3.9.0
sameerank Dec 16, 2024
c2f0c78
v3.9.0-alpha.0
sameerank Dec 16, 2024
ee3409f
Update lock file
sameerank Dec 16, 2024
3410428
Merge branch 'main' into sameeran/ff-3649-offline-init-method-for-pre…
sameerank Jan 8, 2025
3320634
Update to use constructor instead of setters
sameerank Jan 8, 2025
f31efd8
Update js sdk common dependency
sameerank Jan 8, 2025
3f5a2bb
Update common sdk dependency to 4.8.0-alpha.1
sameerank Jan 10, 2025
5f24951
Comply with throwOnFailedInitialization
sameerank Jan 10, 2025
72c6ac3
Remove unneeded reset the static instance before each test
sameerank Jan 10, 2025
5c3712e
Make documentation consistent with the interface
sameerank Jan 10, 2025
d71e589
Remove unnecessary casting to Attribute
sameerank Jan 10, 2025
e094f8e
Don't include logger prefix in thrown error
sameerank Jan 10, 2025
27b413b
Add a shutdown function and test
sameerank Jan 10, 2025
70a0667
Remove comment
sameerank Jan 10, 2025
a2db546
v3.9.2-alpha.0
sameerank Jan 11, 2025
a53962d
Remove application logger input
sameerank Jan 11, 2025
8612f58
Fix test
sameerank Jan 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ help: Makefile
testDataDir := test/data/
tempDir := ${testDataDir}temp/
gitDataDir := ${tempDir}sdk-test-data/
branchName := main
branchName := sameeran/ff-3687-obfuscated-precomputed-json
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Temporarily using this branch for testing until Eppo-exp/sdk-test-data#89 gets an approval 🙏

githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git
.PHONY: test-data
test-data:
rm -rf $(testDataDir)
mkdir -p $(tempDir)
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
cp -r ${gitDataDir}ufc ${testDataDir}
cp -r ${gitDataDir}configuration-wire ${testDataDir}
rm -rf ${tempDir}

## prepare
Expand All @@ -49,4 +50,4 @@ prepare: test-data
## test
.PHONY: test
test: test test-data
yarn test:unit
yarn test:unit
22 changes: 20 additions & 2 deletions js-client-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { IAssignmentLogger } from '@eppo/js-client-sdk-common';
import { IAsyncStore } from '@eppo/js-client-sdk-common';
import { IContainerExperiment } from '@eppo/js-client-sdk-common';
import { ObfuscatedFlag } from '@eppo/js-client-sdk-common';
import { PrecomputedFlag } from '@eppo/js-client-sdk-common';

// @public
export function buildStorageKeySuffix(apiKey: string): string;
Expand Down Expand Up @@ -149,15 +150,32 @@ export function init(config: IClientConfig): Promise<EppoClient>;

// @public
export interface IPrecomputedClientConfig extends IBaseRequestConfig {
subjectAttributes?: Record<string, AttributeType>;
subjectKey: string;
// Warning: (ae-forgotten-export) The symbol "IPrecompute" needs to be exported by the entry point index.d.ts
//
// (undocumented)
precompute: IPrecompute;
}

// @public
export interface IPrecomputedClientConfigSync {
// (undocumented)
assignmentLogger?: IAssignmentLogger;
// (undocumented)
precompute: IPrecompute;
// (undocumented)
precomputedAssignments: Record<string, PrecomputedFlag>;
// (undocumented)
throwOnFailedInitialization?: boolean;
}

export { ObfuscatedFlag }

// @public
export function offlineInit(config: IClientConfigSync): EppoClient;

// @public
export function offlinePrecomputedInit(config: IPrecomputedClientConfigSync): EppoPrecomputedClient;

// @public
export function precomputedInit(config: IPrecomputedClientConfig): Promise<EppoPrecomputedClient>;

Expand Down
13 changes: 10 additions & 3 deletions src/i-client-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { AttributeType, Flag, IAssignmentLogger, IAsyncStore } from '@eppo/js-client-sdk-common';
import { EventDispatcherConfig } from '@eppo/js-client-sdk-common/src/events/default-event-dispatcher';

import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';

Expand Down Expand Up @@ -64,10 +63,10 @@ interface IBaseRequestConfig {
}

/**
* Configuration for Eppo precomputed client initialization
* Configuration for precomputed flag assignments
* @public
*/
export interface IPrecomputedClientConfig extends IBaseRequestConfig {
export interface IPrecompute {
/**
* Subject key to use for precomputed flag assignments.
*/
Expand All @@ -79,6 +78,14 @@ export interface IPrecomputedClientConfig extends IBaseRequestConfig {
subjectAttributes?: Record<string, AttributeType>;
}

/**
* Configuration for Eppo precomputed client initialization
* @public
*/
export interface IPrecomputedClientConfig extends IBaseRequestConfig {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Breaking change to the interface of precomputedInit but assuming it is safe because we are currently the only users of this function

precompute: IPrecompute;
}

/**
* Configuration for regular client initialization
* @public
Expand Down
108 changes: 56 additions & 52 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import * as td from 'testdouble';
import {
getTestAssignments,
IAssignmentTestCase,
MOCK_PRECOMPUTED_RESPONSE_FILE,
MOCK_UFC_RESPONSE_FILE,
OBFUSCATED_MOCK_UFC_RESPONSE_FILE,
readAssignmentTestData,
readMockPrecomputedResponse,
readMockUfcResponse,
validateTestAssignments,
} from '../test/testHelpers';
Expand All @@ -36,6 +38,7 @@ import {
IAssignmentLogger,
init,
offlineInit,
offlinePrecomputedInit,
precomputedInit,
} from './index';

Expand Down Expand Up @@ -1066,59 +1069,14 @@ describe('EppoPrecomputedJSClient E2E test', () => {

beforeAll(async () => {
global.fetch = jest.fn(() => {
const precomputedConfigurationWire = readMockPrecomputedResponse(
MOCK_PRECOMPUTED_RESPONSE_FILE,
);
const precomputedResponse = JSON.parse(precomputedConfigurationWire).precomputed.response;
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
createdAt: '2024-11-18T14:23:39.456Z',
format: 'PRECOMPUTED',
environment: {
name: 'Test',
},
flags: {
'string-flag': {
allocationKey: 'allocation-123',
variationKey: 'variation-123',
variationType: 'STRING',
variationValue: 'red',
extraLogging: {},
doLog: true,
},
'boolean-flag': {
allocationKey: 'allocation-124',
variationKey: 'variation-124',
variationType: 'BOOLEAN',
variationValue: true,
extraLogging: {},
doLog: true,
},
'numeric-flag': {
allocationKey: 'allocation-126',
variationKey: 'variation-126',
variationType: 'NUMERIC',
variationValue: 3.14,
extraLogging: {},
doLog: true,
},
'integer-flag': {
allocationKey: 'allocation-125',
variationKey: 'variation-125',
variationType: 'INTEGER',
variationValue: 42,
extraLogging: {},
doLog: true,
},
'json-flag': {
allocationKey: 'allocation-127',
variationKey: 'variation-127',
variationType: 'JSON',
variationValue: '{"key": "value", "number": 123}',
extraLogging: {},
doLog: true,
},
},
}),
json: () => Promise.resolve(precomputedResponse),
});
}) as jest.Mock;

Expand All @@ -1128,8 +1086,10 @@ describe('EppoPrecomputedJSClient E2E test', () => {
apiKey: 'dummy',
baseUrl: 'http://127.0.0.1:4000',
assignmentLogger: mockLogger,
subjectKey: 'test-subject',
subjectAttributes: { attr1: 'value1' },
precompute: {
subjectKey: 'test-subject',
subjectAttributes: { attr1: 'value1' },
},
});
});

Expand Down Expand Up @@ -1179,6 +1139,50 @@ describe('EppoPrecomputedJSClient E2E test', () => {
});
});

describe('offlinePrecomputedInit', () => {
let mockLogger: IAssignmentLogger;
let precomputedConfigurationWire: string;

beforeAll(() => {
precomputedConfigurationWire = readMockPrecomputedResponse(MOCK_PRECOMPUTED_RESPONSE_FILE);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently, tests use the unobfuscated, unstringified ConfigurationWire format https://github.com/Eppo-exp/sdk-test-data/blob/8ee55117b7c7f5d02b2997629af0f0fa93e4e97c/configuration-wire/precomputed-v1.json

In a follow up PR I will add support for parsing json strings and handling obfuscated fields

});

beforeEach(() => {
mockLogger = td.object<IAssignmentLogger>();
// Reset the static instance before each test
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('./index');
sameerank marked this conversation as resolved.
Show resolved Hide resolved
});
});

it('initializes with precomputed assignments', () => {
const client = offlinePrecomputedInit({
precomputedConfigurationWire,
assignmentLogger: mockLogger,
});

expect(client.getStringAssignment('string-flag', 'default')).toBe('red');
expect(td.explain(mockLogger.logAssignment).callCount).toBe(1);
expect(td.explain(mockLogger.logAssignment).calls[0]?.args[0]).toMatchObject({
subject: 'test-subject-key',
featureFlag: 'string-flag',
allocation: 'allocation-123',
variation: 'variation-123',
subjectAttributes: {
device: 'iPhone',
country: 'USA',
},
});
});

it('initializes without an assignment logger', () => {
const client = offlinePrecomputedInit({ precomputedConfigurationWire });

expect(client.getStringAssignment('string-flag', 'default')).toBe('red');
});
});

describe('EppoClient config', () => {
it('should initialize event dispatcher with default values', async () => {
global.fetch = jest.fn(() => {
Expand Down
100 changes: 92 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
ObfuscatedFlag,
BoundedEventQueue,
validation,
PrecomputedFlag,
Event,
} from '@eppo/js-client-sdk-common';
import { Environment, FormatEnum } from '@eppo/js-client-sdk-common/dist/interfaces';

import { assignmentCacheFactory } from './cache/assignment-cache-factory';
import HybridAssignmentCache from './cache/hybrid-assignment-cache';
Expand All @@ -32,7 +34,7 @@
} from './configuration-factory';
import BrowserNetworkStatusListener from './events/browser-network-status-listener';
import LocalStorageBackedNamedEventQueue from './events/local-storage-backed-named-event-queue';
import { IClientConfig, IPrecomputedClientConfig } from './i-client-config';
import { IClientConfig, IPrecompute, IPrecomputedClientConfig } from './i-client-config';

Check warning on line 37 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

'IPrecompute' is defined but never used

Check warning on line 37 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

'IPrecompute' is defined but never used

Check warning on line 37 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

'IPrecompute' is defined but never used

Check warning on line 37 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

'IPrecompute' is defined but never used
import { sdkName, sdkVersion } from './sdk-data';

/**
Expand Down Expand Up @@ -465,7 +467,7 @@
// both failed, make the "fatal" error the fetch one
initializationError = initFromFetchError;
}
} catch (error: any) {

Check warning on line 470 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

Unexpected any. Specify a different type

Check warning on line 470 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

Unexpected any. Specify a different type

Check warning on line 470 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

Unexpected any. Specify a different type

Check warning on line 470 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

Unexpected any. Specify a different type
initializationError = error;
}

Expand Down Expand Up @@ -558,13 +560,12 @@
config: IPrecomputedClientConfig,
): Promise<EppoPrecomputedClient> {
validation.validateNotBlank(config.apiKey, 'API key required');
validation.validateNotBlank(config.subjectKey, 'Subject key required');
validation.validateNotBlank(config.precompute.subjectKey, 'Subject key required');

const instance = EppoPrecomputedJSClient.instance;
const {
apiKey,
subjectKey,
subjectAttributes = {},
precompute: { subjectKey, subjectAttributes = {} },
baseUrl,
requestTimeoutMs,
numInitialRequestRetries,
Expand All @@ -584,10 +585,7 @@
sdkName,
sdkVersion,
baseUrl,
precompute: {
subjectKey,
subjectAttributes,
},
precompute: { subjectKey, subjectAttributes },
requestTimeoutMs,
numInitialRequestRetries,
numPollRequestRetries,
Expand All @@ -605,6 +603,92 @@
return EppoPrecomputedJSClient.instance;
}

/**
* Initializes the Eppo precomputed client with configuration parameters.
*
* The purpose is for use-cases where the precomputed assignments are available from an external process
* that can bootstrap the SDK.
*
* This method should be called once on application startup.
*
* @param config - client configuration
* @returns a singleton precomputed client instance
* @public
*/
sameerank marked this conversation as resolved.
Show resolved Hide resolved
sameerank marked this conversation as resolved.
Show resolved Hide resolved
export interface IPrecomputedClientConfigSync {
precomputedConfigurationWire: string;
assignmentLogger?: IAssignmentLogger;
throwOnFailedInitialization?: boolean;
}

export interface IConfigurationWire {
version: number;
precomputed: {
subjectKey: string;
subjectAttributes: Record<string, AttributeType>;
fetchedAt: string;
response: {
createdAt: string;
format: FormatEnum;
obfuscated: boolean;
environment: Environment;
flags: Record<string, PrecomputedFlag>;
};
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will replace with an exported interface from Eppo-exp/js-sdk-common#160


/**
* Initializes the Eppo precomputed client with configuration parameters.
*
* The purpose is for use-cases where the precomputed assignments are available from an external process
* that can bootstrap the SDK.
*
* This method should be called once on application startup.
*
* @param config - precomputed client configuration
* @returns a singleton precomputed client instance
* @public
*/
export function offlinePrecomputedInit(
config: IPrecomputedClientConfigSync,
): EppoPrecomputedClient {
const throwOnFailedInitialization = config.throwOnFailedInitialization ?? true;

const configurationWire: IConfigurationWire = JSON.parse(config.precomputedConfigurationWire);
const { subjectKey, subjectAttributes, response } = configurationWire.precomputed;
const parsedResponse = response; // TODO: use a JSON.parse when the obfuscated version is usable

try {
const memoryOnlyPrecomputedStore = precomputedFlagsStorageFactory();
memoryOnlyPrecomputedStore
.setEntries(parsedResponse.flags)
.catch((err) =>
applicationLogger.warn('Error setting precomputed assignments for memory-only store', err),
);

EppoPrecomputedJSClient.instance.setSubjectAndPrecomputedFlagStore(
subjectKey,
subjectAttributes,
memoryOnlyPrecomputedStore,
);

if (config.assignmentLogger) {
EppoPrecomputedJSClient.instance.setAssignmentLogger(config.assignmentLogger);
}
} catch (error) {
applicationLogger.warn(
'Eppo SDK encountered an error initializing precomputed client, assignment calls will return the default value and not be logged',
);
if (throwOnFailedInitialization) {
throw error;
}
}

EppoPrecomputedJSClient.instance.setIsObfuscated(parsedResponse.obfuscated);
sameerank marked this conversation as resolved.
Show resolved Hide resolved
EppoPrecomputedJSClient.initialized = true;
return EppoPrecomputedJSClient.instance;
}

/**
* Used to access a singleton SDK precomputed client instance.
* Use the method after calling precomputedInit() to initialize the client.
Expand Down
Loading
Loading