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: non-singleton EppoJSClient #166

Open
wants to merge 14 commits into
base: typo/no-singleton
Choose a base branch
from
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"webpack-cli": "^6.0.1"
},
"dependencies": {
"@eppo/js-client-sdk-common": "4.8.4"
"@eppo/js-client-sdk-common": "4.9.0"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
134 changes: 134 additions & 0 deletions src/client-options-converter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {
IConfigurationStore,
ObfuscatedFlag,
Flag,
EventDispatcher,
} from '@eppo/js-client-sdk-common';
import * as td from 'testdouble';

import { clientOptionsToEppoClientParameters } from './client-options-converter';
import { IClientOptions } from './i-client-config';
import { sdkName, sdkVersion } from './sdk-data';

describe('clientOptionsToParameters', () => {
const mockStore = td.object<IConfigurationStore<Flag | ObfuscatedFlag>>();

it('converts basic client options', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
baseUrl: 'https://test.eppo.cloud',
assignmentLogger: { logAssignment: jest.fn() },
};

const result = clientOptionsToEppoClientParameters(options, mockStore);

expect(result.isObfuscated).toBe(true);
expect(result.flagConfigurationStore).toBeDefined();
expect(result.configurationRequestParameters).toEqual({
apiKey: 'test-key',
baseUrl: 'https://test.eppo.cloud',
sdkName,
sdkVersion,
numInitialRequestRetries: undefined,
numPollRequestRetries: undefined,
pollingIntervalMs: undefined,
requestTimeoutMs: undefined,
pollAfterFailedInitialization: undefined,
pollAfterSuccessfulInitialization: undefined,
throwOnFailedInitialization: undefined,
skipInitialPoll: undefined,
});
});

it('uses provided flag configuration store', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
assignmentLogger: { logAssignment: jest.fn() },
};

const result = clientOptionsToEppoClientParameters(options, mockStore);

expect(result.flagConfigurationStore).toBe(mockStore);
});

it('converts client options with event ingestion config', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
assignmentLogger: { logAssignment: jest.fn() },
};
const mockDispatcher: EventDispatcher = td.object<EventDispatcher>();

const result = clientOptionsToEppoClientParameters(options, mockStore, mockDispatcher);

expect(result.eventDispatcher).toBeDefined();
});

it('converts client options with polling configuration', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
assignmentLogger: { logAssignment: jest.fn() },
pollingIntervalMs: 30000,
pollAfterSuccessfulInitialization: true,
pollAfterFailedInitialization: true,
skipInitialRequest: true,
};

const result = clientOptionsToEppoClientParameters(options, mockStore);

expect(result.configurationRequestParameters).toMatchObject({
pollingIntervalMs: 30000,
pollAfterSuccessfulInitialization: true,
pollAfterFailedInitialization: true,
skipInitialPoll: true,
});
});

it('converts client options with retry configuration', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
assignmentLogger: { logAssignment: jest.fn() },
requestTimeoutMs: 5000,
numInitialRequestRetries: 3,
numPollRequestRetries: 2,
};

const result = clientOptionsToEppoClientParameters(options, mockStore);

expect(result.configurationRequestParameters).toMatchObject({
requestTimeoutMs: 5000,
numInitialRequestRetries: 3,
numPollRequestRetries: 2,
});
});

it('handles undefined optional parameters', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
assignmentLogger: { logAssignment: jest.fn() },
};

const result = clientOptionsToEppoClientParameters(options, mockStore);

expect(result.configurationRequestParameters).toMatchObject({
baseUrl: undefined,
pollingIntervalMs: undefined,
requestTimeoutMs: undefined,
numInitialRequestRetries: undefined,
numPollRequestRetries: undefined,
});
});

it('includes sdk metadata', () => {
const options: IClientOptions = {
sdkKey: 'test-key',
assignmentLogger: { logAssignment: jest.fn() },
};

const result = clientOptionsToEppoClientParameters(options, mockStore);

expect(result.configurationRequestParameters).toMatchObject({
sdkName,
sdkVersion,
});
});
});
44 changes: 44 additions & 0 deletions src/client-options-converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
EppoClientParameters,
EventDispatcher,
Flag,
IConfigurationStore,
} from '@eppo/js-client-sdk-common';

import { IClientOptions } from './i-client-config';
import { sdkName, sdkVersion } from './sdk-data';

/**
* Converts IClientOptions to EppoClientParameters
* @internal
*/
export function clientOptionsToEppoClientParameters(
options: IClientOptions,
flagConfigurationStore: IConfigurationStore<Flag>,
eventDispatcher?: EventDispatcher,
): EppoClientParameters {
const parameters: EppoClientParameters = {
flagConfigurationStore,
isObfuscated: true,
};

parameters.eventDispatcher = eventDispatcher;
typotter marked this conversation as resolved.
Show resolved Hide resolved

// Always include configuration request parameters
parameters.configurationRequestParameters = {
apiKey: options.sdkKey,
sdkVersion, // dynamically picks up version.
sdkName, // Hardcoded to `js-client-sdk`
baseUrl: options.baseUrl,
requestTimeoutMs: options.requestTimeoutMs,
numInitialRequestRetries: options.numInitialRequestRetries,
numPollRequestRetries: options.numPollRequestRetries,
pollAfterSuccessfulInitialization: options.pollAfterSuccessfulInitialization,
pollAfterFailedInitialization: options.pollAfterFailedInitialization,
pollingIntervalMs: options.pollingIntervalMs,
throwOnFailedInitialization: options.throwOnFailedInitialization,
skipInitialPoll: options.skipInitialRequest,
};
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like it could e a pain to keep in sync. If these parameters are just a supserset, could we use the spread operator instead?

const parameters = {...options, eventDispatcher, isObfuscated: true}

Side note: I've heard a couple of places they'd like to be able to tell client not to obfuscate, such as in development environments. So I wonder if we take this opportunity to make isObfuscated configurable.

Choose a reason for hiding this comment

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

isObfuscated is actually a booby trap.

The client doesn't obfuscate anything (it only de-obfuscates), except a couple of log fields(?). Whether configuration is obfuscated or not is determined by the server based on sdkName/sdkVersion. isObfuscated is a flag that says "we know that server will serve obfuscated config for my sdkName/sdkVersion pair." You can't change isObfuscated without things blowing up.

isObfuscated should actually be removed altogether because we have format and obfuscated fields in configurations which determine whether configuration is really obfuscated.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

removed


return parameters;
}
125 changes: 111 additions & 14 deletions src/i-client-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
IAssignmentLogger,
IAsyncStore,
IBanditLogger,
IConfigurationStore,
} from '@eppo/js-client-sdk-common';

import { ServingStoreUpdateStrategy } from './isolatable-hybrid.store';
Expand Down Expand Up @@ -104,10 +105,41 @@
}

/**
* Configuration for regular client initialization
* @public
* Base options for the EppoClient SDK
*/
export interface IClientConfig extends IBaseRequestConfig {
export type IApiOptions = {
/**
* Your key for accessing Eppo through the Eppo SDK.
*/
sdkKey: string;
typotter marked this conversation as resolved.
Show resolved Hide resolved

/**
* Override the endpoint the SDK uses to load configuration.
*/
baseUrl?: string;

/**
* Force reinitialize the SDK if it is already initialized.
*/
forceReinitialize?: boolean;
Comment on lines +121 to +124

Choose a reason for hiding this comment

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

As per my other comment, I propose to forbid re-initialization of standalone clients (because it's buggy and it's now possible to create new clients). While we can postpone the implementation of that, I believe that buildAndInit should not accept forceReinitialize already (as removing it later will be a major change)


/**
* Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000)
*/
requestTimeoutMs?: number;

/**
* Number of additional times the initial configuration request will be attempted if it fails.
* This is the request typically synchronously waited (via await) for completion. A small wait will be
* done between requests. (Default: 1)
*/
numInitialRequestRetries?: number;

/**
* Skip the request for new configurations during initialization. (default: false)
*/
skipInitialRequest?: boolean;

/**
* Throw an error if unable to fetch an initial configuration during initialization. (default: true)
*/
Expand All @@ -134,19 +166,11 @@
*/
updateOnFetch?: ServingStoreUpdateStrategy;

/**
* A custom class to use for storing flag configurations.
* This is useful for cases where you want to use a different storage mechanism
* than the default storage provided by the SDK.
*/
persistentStore?: IAsyncStore<Flag>;
// TODO: Add initial config (stringified IConfigurationWire) here.

Choose a reason for hiding this comment

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

minor: it's better to add a proper Configuration class instead of passing untyped/unvalidated strings

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We have the client returning a stringified IConfigurationWire so devs don't need to worry about serializing the config string.

Choose a reason for hiding this comment

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

In my ideal world, we have a Configuration class with toString() and fromString() methods. This way, devs still don't need to worry about serialization but they have a stronger hint at what they are expected to pass in

};

/**
* Force reinitialize the SDK if it is already initialized.
*/
forceReinitialize?: boolean;

Check warning on line 172 in src/i-client-config.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

Delete `⏎`

Check warning on line 172 in src/i-client-config.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

Delete `⏎`

Check warning on line 172 in src/i-client-config.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

Delete `⏎`

Check warning on line 172 in src/i-client-config.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

Delete `⏎`
/** Configuration settings for the event dispatcher */

Choose a reason for hiding this comment

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

This doc string is now missing

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fixed

export type IEventOptions = {
eventIngestionConfig?: {
/** Number of milliseconds to wait between each batch delivery. Defaults to 10 seconds. */
deliveryIntervalMs?: number;
Expand All @@ -165,4 +189,77 @@
*/
maxQueueSize?: number;
};
};

export type IStorageOptions = {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think storage uniqueness is based on SDK which should be fine for this use case as I don't imagine multiple instances all using same SDK key, but I wonder if we should include that in the documentation somewhere

/**
* Custom implementation of the flag configuration store for advanced use-cases.
*/
flagConfigurationStore?: IConfigurationStore<Flag>;

/**
* A custom class to use for storing flag configurations.
* This is useful for cases where you want to use a different storage mechanism
* than the default storage provided by the SDK.
*/
persistentStore?: IAsyncStore<Flag>;
};

export type IPollingOptions = {
Copy link
Contributor

Choose a reason for hiding this comment

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

oooh I like this additional organization

/**
* Poll for new configurations even if the initial configuration request failed. (default: false)
*/
pollAfterFailedInitialization?: boolean;

/**
* Poll for new configurations (every `pollingIntervalMs`) after successfully requesting the initial configuration. (default: false)
*/
pollAfterSuccessfulInitialization?: boolean;

/**
* Amount of time to wait between API calls to refresh configuration data. Default of 30_000 (30 seconds).
*/
pollingIntervalMs?: number;

/**
* Number of additional times polling for updated configurations will be attempted before giving up.
* Polling is done after a successful initial request. Subsequent attempts are done using an exponential
* backoff. (Default: 7)
*/
numPollRequestRetries?: number;
};

export type ILoggers = {
/**
* Pass a logging implementation to send variation assignments to your data warehouse.
*/
assignmentLogger: IAssignmentLogger;

/**
* Pass a logging implementation to send bandit assignments to your data warehouse.
*/
banditLogger?: IBanditLogger;
};

/**
* Config shape for client v2.
*/
export type IClientOptions = IApiOptions &
ILoggers &
IEventOptions &
IStorageOptions &
IPollingOptions;

Choose a reason for hiding this comment

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

Are these sub-types ever used separately? Having the type split like this increases the chance of getting name clashes on the fields (which typescript won't really warn us about)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The idea is that a dev could build different options objects then combine them for initialization. If they don't need custom event and storage options, for example, they wouldn't have those configs.

Choose a reason for hiding this comment

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

Could you give an example of how you see it used in users' code?


/**
* Configuration for regular client initialization
* @public
*/
export type IClientConfig = Omit<IClientOptions, 'sdkKey' | 'offline'> &
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the omit/pick makes it clear what is happening

Copy link
Contributor

Choose a reason for hiding this comment

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

These types are so similar is it worth having separate ones for the constructor vs. init? What if we just allow sdkKey or apiKey for backward compatibility? I worry having two constructor input types could be confusing

Choose a reason for hiding this comment

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

I would actually keep apiKey and save sdkKey rename for the next major bump

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fixed up. moved sdkKey to the next major

Pick<IBaseRequestConfig, 'apiKey'>; // Could also just use `& IBaseRequestConfig` here instead of picking just `apiKey`.

export function convertClientOptionsToClientConfig(options: IClientOptions): IClientConfig {
return {
...options,
apiKey: options.sdkKey,
};
}
Loading