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: local evaluation #6

Merged
merged 16 commits into from
Jul 17, 2022
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ dist/
.cache
.DS_Store

# WebStorm IDE
.idea
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist/
CHANGELOG.md
gen/
CHANGELOG.md
7 changes: 5 additions & 2 deletions packages/node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@amplitude/experiment-node-server",
"version": "1.0.3",
"version": "1.1.0-alpha.10",
"description": "Javascript Server SDK for Amplitude Experiment",
"main": "dist/src/index.js",
"files": [
Expand Down Expand Up @@ -28,5 +28,8 @@
"url": "https://github.com/amplitude/experiment-node-server/issues"
},
"homepage": "https://github.com/amplitude/experiment-node-server#readme",
"gitHead": "e8ceef5275b6daf4f7f26c6ac2e1683eeba7aca6"
"gitHead": "e8ceef5275b6daf4f7f26c6ac2e1683eeba7aca6",
"dependencies": {
"@amplitude/evaluation-js": "0.0.4"
}
}
75 changes: 0 additions & 75 deletions packages/node/src/config.ts

This file was deleted.

File renamed without changes.
70 changes: 62 additions & 8 deletions packages/node/src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,84 @@
import { ExperimentClient } from './client';
import { ExperimentConfig } from './config';
import { InMemoryFlagConfigCache } from './local/cache';
import { LocalEvaluationClient } from './local/client';
import { ExperimentClient, RemoteEvaluationClient } from './remote/client';
import {
ExperimentConfig,
RemoteEvaluationConfig,
LocalEvaluationConfig,
} from './types/config';

const remoteEvaluationInstances = {};
const localEvaluationInstances = {};

const instances = {};
const defaultInstance = '$default_instance';

/**
* Initializes a singleton {@link ExperimentClient}.
* Initializes a singleton {@link ExperimentClient} for remote evaluation.
*
* @param apiKey The environment API Key
* @param config See {@link ExperimentConfig} for config options
* @deprecated use initializeRemote
*/
const initialize = (
apiKey: string,
config?: ExperimentConfig,
): ExperimentClient => {
if (!instances[defaultInstance]) {
instances[defaultInstance] = new ExperimentClient(apiKey, config);
return initializeRemote(apiKey, config) as ExperimentClient;
};

/**
* Initializes a singleton {@link ExperimentClient} for remote evaluation.
*
* @param apiKey The environment API Key
* @param config See {@link ExperimentConfig} for config options
*/
const initializeRemote = (
apiKey: string,
config?: RemoteEvaluationConfig,
): RemoteEvaluationClient => {
if (!remoteEvaluationInstances[defaultInstance]) {
remoteEvaluationInstances[defaultInstance] = new RemoteEvaluationClient(
apiKey,
config,
);
}
return remoteEvaluationInstances[defaultInstance];
};

/**
* Initialize a local evaluation client.
*
* A local evaluation client can evaluate local flags or experiments for a user
* without requiring a remote call to the amplitude evaluation server. In order
* to best leverage local evaluation, all flags and experiments being evaluated
* server side should be configured as local.
*
* @param apiKey The environment API Key
* @param config See {@link ExperimentConfig} for config options
* @returns The local evaluation client.
*/
const initializeLocal = (
apiKey: string,
config?: LocalEvaluationConfig,
): LocalEvaluationClient => {
if (!localEvaluationInstances[apiKey]) {
localEvaluationInstances[apiKey] = new LocalEvaluationClient(
apiKey,
config,
new InMemoryFlagConfigCache(),
);
}
return instances[defaultInstance];
return localEvaluationInstances[apiKey];
};

/**
* Provides factory methods for storing singleton instances of
* {@link ExperimentClient}.
*
* Provides factory methods for storing singleton instances of {@link ExperimentClient}
* @category Core Usage
*/
export const Experiment = {
initialize,
initializeRemote,
initializeLocal,
};
19 changes: 13 additions & 6 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
/**
* This is the API Reference for the Experiment JS Server SDK.
* For more details on implementing this SDK, view the documentation
* [here](https://amplitude-lab.readme.io/docs/javascript-server-sdk).
* This is the API Reference for the Experiment Node.js Server SDK.
* For more details on implementing this SDK, view the [documentation]
* (https://docs.developers.amplitude.com/experiment/sdks/nodejs-sdk).
* @module experiment-node-server
*/

export { AmplitudeCookie } from './amplitude';
export { ExperimentClient } from './client';
export { ExperimentConfig, Defaults } from './config';
export { AmplitudeCookie } from './cookie';
export { ExperimentClient } from './remote/client';
export { ExperimentConfig, RemoteEvaluationDefaults } from './types/config';
export { Experiment } from './factory';
export { ExperimentUser } from './types/user';
export { Variant, Variants } from './types/variant';

export { LocalEvaluationClient } from './local/client';
export { LocalEvaluationConfig } from './types/config';
export { FlagConfigFetcher } from './local/fetcher';
export { FlagConfigPoller } from './local/poller';
export { InMemoryFlagConfigCache } from './local/cache';
export { FlagConfig, FlagConfigCache } from './types/flag';
32 changes: 32 additions & 0 deletions packages/node/src/local/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FlagConfigCache, FlagConfig } from '../types/flag';

export class InMemoryFlagConfigCache implements FlagConfigCache {
private cache: Record<string, FlagConfig> = {};

public constructor(flagConfigs: Record<string, FlagConfig> = {}) {
this.cache = flagConfigs;
}
public async get(flagKey: string): Promise<FlagConfig> {
return this.cache[flagKey];
}
public async getAll(): Promise<Record<string, FlagConfig>> {
return { ...this.cache };
}
public async put(flagKey: string, flagConfig: FlagConfig): Promise<void> {
this.cache[flagKey] = flagConfig;
}
public async putAll(flagConfigs: Record<string, FlagConfig>): Promise<void> {
for (const key in flagConfigs) {
const flag = flagConfigs[key];
if (flag) {
this.cache[key] = flag;
}
}
}
public async delete(flagKey: string): Promise<void> {
delete this.cache[flagKey];
}
public async clear(): Promise<void> {
this.cache = {};
}
}
123 changes: 123 additions & 0 deletions packages/node/src/local/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import evaluation from '@amplitude/evaluation-js';

import { FetchHttpClient } from '../transport/http';
import {
LocalEvaluationConfig,
LocalEvaluationDefaults,
} from '../types/config';
import { FlagConfig, FlagConfigCache } from '../types/flag';
import { HttpClient } from '../types/transport';
import { ExperimentUser } from '../types/user';
import { Variants } from '../types/variant';
import { ConsoleLogger } from '../util/logger';
import { Logger } from '../util/logger';

import { InMemoryFlagConfigCache } from './cache';
import { FlagConfigFetcher } from './fetcher';
import { FlagConfigPoller } from './poller';

/**
* Experiment client for evaluating variants for a user locally.
* @category Core Usage
*/
export class LocalEvaluationClient {
private readonly logger: Logger;
private readonly config: LocalEvaluationConfig;
private readonly poller: FlagConfigPoller;

/**
* Directly access the client's flag config cache.
*
* Used for directly manipulating the flag configs used for evaluation.
*/
public readonly cache: FlagConfigCache;

constructor(
apiKey: string,
config: LocalEvaluationConfig,
flagConfigCache: FlagConfigCache = new InMemoryFlagConfigCache(
config?.bootstrap,
),
httpClient: HttpClient = new FetchHttpClient(config?.httpAgent),
) {
this.config = { ...LocalEvaluationDefaults, ...config };
const fetcher = new FlagConfigFetcher(
apiKey,
httpClient,
this.config.serverUrl,
this.config.debug,
);
this.cache = flagConfigCache;
this.logger = new ConsoleLogger(this.config.debug);
this.poller = new FlagConfigPoller(
fetcher,
this.cache,
this.config.flagConfigPollingIntervalMillis,
this.config.debug,
);
}

/**
* Locally evaluates flag variants for a user.
*
* This function will only evaluate flags for the keys specified in the
* {@link flagKeys} argument. If {@link flagKeys} is missing, all flags in the
* {@link FlagConfigCache} will be evaluated.
*
* @param user The user to evaluate
* @param flagKeys The flags to evaluate with the user. If empty, all flags
* from the flag cache are evaluated.
* @returns The evaluated variants
*/
public async evaluate(
user: ExperimentUser,
flagKeys?: string[],
): Promise<Variants> {
const flagConfigs = await this.getFlagConfigs(flagKeys);
this.logger.debug(
'[Experiment] evaluate - user:',
user,
'flagConfigs:',
flagConfigs,
);
const results: Variants = evaluation.evaluate(flagConfigs, user);
this.logger.debug('[Experiment] evaluate - result: ', results);
return results;
}

/**
* Fetch initial flag configurations and start polling for updates.
*
* You must call this function to begin polling for flag config updates.
* The promise returned by this function is resolved when the initial call
* to fetch the flag configuration completes.
*
* Calling this function while the poller is already running does nothing.
*/
public async start(): Promise<void> {
return await this.poller.start();
}

/**
* Stop polling for flag configurations.
*
* Calling this function while the poller is not running will do nothing.
*/
public stop(): void {
return this.poller.stop();
}

private async getFlagConfigs(flagKeys?: string[]): Promise<FlagConfig[]> {
if (!flagKeys) {
return Object.values(await this.cache.getAll());
}
const result: FlagConfig[] = [];
for (const key of flagKeys) {
const flagConfig = await this.cache.get(key);
if (flagConfig) {
result.push(flagConfig);
}
}
return result;
}
}
Loading