From 5443a7a95cea956502e1722673b473591ed0d82b Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Fri, 1 Dec 2023 08:40:24 -0800 Subject: [PATCH] feat: evaluation v2 (#36) --- packages/node/package.json | 2 +- .../node/src/assignment/assignment-service.ts | 71 +++++----- packages/node/src/assignment/assignment.ts | 16 ++- packages/node/src/local/cache.ts | 18 ++- packages/node/src/local/client.ts | 102 +++++++------- packages/node/src/local/fetcher.ts | 53 +++---- packages/node/src/remote/client.ts | 133 ++++++++---------- packages/node/src/transport/http.ts | 28 ++++ packages/node/src/types/config.ts | 2 - packages/node/src/types/flag.ts | 4 +- packages/node/src/types/variant.ts | 29 ++-- packages/node/src/util/user.ts | 41 ++++++ packages/node/src/util/variant.ts | 46 ++++++ .../assignment/assignment-filter.test.ts | 123 ++++------------ .../assignment/assignment-service.test.ts | 133 ++++++++++++++---- packages/node/test/local/client.test.ts | 88 ++++++++++-- .../node/test/local/flagConfigFetcher.test.ts | 2 +- packages/node/test/remote/client.test.ts | 22 +-- packages/node/test/util/user.test.ts | 91 ++++++++++++ packages/node/test/util/variant.test.ts | 92 ++++++++++++ yarn.lock | 15 +- 21 files changed, 748 insertions(+), 363 deletions(-) create mode 100644 packages/node/src/util/user.ts create mode 100644 packages/node/src/util/variant.ts create mode 100644 packages/node/test/util/user.test.ts create mode 100644 packages/node/test/util/variant.test.ts diff --git a/packages/node/package.json b/packages/node/package.json index 46a7bb2..276eeb3 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -32,6 +32,6 @@ "dependencies": { "@amplitude/analytics-node": "^1.3.4", "@amplitude/analytics-types": "^1.3.1", - "@amplitude/evaluation-js": "1.1.1" + "@amplitude/experiment-core": "^0.7.1" } } diff --git a/packages/node/src/assignment/assignment-service.ts b/packages/node/src/assignment/assignment-service.ts index 0794990..3cf24c9 100644 --- a/packages/node/src/assignment/assignment-service.ts +++ b/packages/node/src/assignment/assignment-service.ts @@ -7,7 +7,6 @@ import { Assignment, AssignmentFilter, AssignmentService } from './assignment'; export const DAY_MILLIS = 24 * 60 * 60 * 1000; export const FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual-exclusion-group'; -export const FLAG_TYPE_HOLDOUT_GROUP = 'holdout-group'; export class AmplitudeAssignmentService implements AssignmentService { private readonly amplitude: CoreClient; @@ -20,44 +19,48 @@ export class AmplitudeAssignmentService implements AssignmentService { async track(assignment: Assignment): Promise { if (this.assignmentFilter.shouldTrack(assignment)) { - this.amplitude.logEvent(this.toEvent(assignment)); + this.amplitude.logEvent(toEvent(assignment)); } } +} - public toEvent(assignment: Assignment): BaseEvent { - const event: BaseEvent = { - event_type: '[Experiment] Assignment', - user_id: assignment.user.user_id, - device_id: assignment.user.device_id, - event_properties: {}, - user_properties: {}, - }; - - for (const resultsKey in assignment.results) { - event.event_properties[`${resultsKey}.variant`] = - assignment.results[resultsKey].value; +export const toEvent = (assignment: Assignment): BaseEvent => { + const event: BaseEvent = { + event_type: '[Experiment] Assignment', + user_id: assignment.user.user_id, + device_id: assignment.user.device_id, + event_properties: {}, + user_properties: {}, + }; + const set = {}; + const unset = {}; + for (const flagKey in assignment.results) { + const variant = assignment.results[flagKey]; + if (!variant.key) { + continue; } - - const set = {}; - const unset = {}; - for (const resultsKey in assignment.results) { - if ( - assignment.results[resultsKey].type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP - ) { - continue; - } else if (assignment.results[resultsKey].isDefaultVariant) { - unset[`[Experiment] ${resultsKey}`] = '-'; + const version = variant.metadata?.flagVersion; + const segmentName = variant.metadata?.segmentName; + const flagType = variant.metadata?.flagType; + const isDefault: boolean = variant.metadata?.default as boolean; + event.event_properties[`${flagKey}.variant`] = variant.key; + if (version && segmentName) { + event.event_properties[ + `${flagKey}.details` + ] = `v${version} rule:${segmentName}`; + } + if (flagType != FLAG_TYPE_MUTUAL_EXCLUSION_GROUP) { + if (isDefault) { + unset[`[Experiment] ${flagKey}`] = '-'; } else { - set[`[Experiment] ${resultsKey}`] = - assignment.results[resultsKey].value; + set[`[Experiment] ${flagKey}`] = variant.key; } } - event.user_properties['$set'] = set; - event.user_properties['$unset'] = unset; - - event.insert_id = `${event.user_id} ${event.device_id} ${hashCode( - assignment.canonicalize(), - )} ${Math.floor(assignment.timestamp / DAY_MILLIS)}`; - return event; } -} + event.user_properties['$set'] = set; + event.user_properties['$unset'] = unset; + event.insert_id = `${event.user_id} ${event.device_id} ${hashCode( + assignment.canonicalize(), + )} ${Math.floor(assignment.timestamp / DAY_MILLIS)}`; + return event; +}; diff --git a/packages/node/src/assignment/assignment.ts b/packages/node/src/assignment/assignment.ts index 10cca9a..5e8a228 100644 --- a/packages/node/src/assignment/assignment.ts +++ b/packages/node/src/assignment/assignment.ts @@ -1,5 +1,6 @@ +import { EvaluationVariant } from '@amplitude/experiment-core'; + import { ExperimentUser } from '../types/user'; -import { Results } from '../types/variant'; export interface AssignmentService { track(assignment: Assignment): Promise; @@ -11,10 +12,13 @@ export interface AssignmentFilter { export class Assignment { public user: ExperimentUser; - public results: Results; + public results: Record; public timestamp: number = Date.now(); - public constructor(user: ExperimentUser, results: Results) { + public constructor( + user: ExperimentUser, + results: Record, + ) { this.user = user; this.results = results; } @@ -22,8 +26,10 @@ export class Assignment { public canonicalize(): string { let canonical = `${this.user.user_id?.trim()} ${this.user.device_id?.trim()} `; for (const key of Object.keys(this.results).sort()) { - const value = this.results[key]; - canonical += key.trim() + ' ' + value?.value?.trim() + ' '; + const variant = this.results[key]; + if (variant?.key) { + canonical += key.trim() + ' ' + variant?.key?.trim() + ' '; + } } return canonical; } diff --git a/packages/node/src/local/cache.ts b/packages/node/src/local/cache.ts index c873236..a327a44 100644 --- a/packages/node/src/local/cache.ts +++ b/packages/node/src/local/cache.ts @@ -1,11 +1,21 @@ import { FlagConfigCache, FlagConfig } from '../types/flag'; export class InMemoryFlagConfigCache implements FlagConfigCache { - private cache: Record = {}; + private readonly store: FlagConfigCache | undefined; + private cache: Record; - public constructor(flagConfigs: Record = {}) { + public constructor( + store?: FlagConfigCache, + flagConfigs: Record = {}, + ) { + this.store = store; this.cache = flagConfigs; } + + public getAllCached(): Record { + return { ...this.cache }; + } + public async get(flagKey: string): Promise { return this.cache[flagKey]; } @@ -14,6 +24,7 @@ export class InMemoryFlagConfigCache implements FlagConfigCache { } public async put(flagKey: string, flagConfig: FlagConfig): Promise { this.cache[flagKey] = flagConfig; + await this.store?.put(flagKey, flagConfig); } public async putAll(flagConfigs: Record): Promise { for (const key in flagConfigs) { @@ -22,11 +33,14 @@ export class InMemoryFlagConfigCache implements FlagConfigCache { this.cache[key] = flag; } } + await this.store?.putAll(flagConfigs); } public async delete(flagKey: string): Promise { delete this.cache[flagKey]; + await this.store?.delete(flagKey); } public async clear(): Promise { this.cache = {}; + await this.store?.clear(); } } diff --git a/packages/node/src/local/client.ts b/packages/node/src/local/client.ts index cb634cb..363e15d 100644 --- a/packages/node/src/local/client.ts +++ b/packages/node/src/local/client.ts @@ -1,13 +1,13 @@ import * as amplitude from '@amplitude/analytics-node'; -import evaluation from '@amplitude/evaluation-js'; +import { + EvaluationEngine, + EvaluationFlag, + topologicalSort, +} from '@amplitude/experiment-core'; import { Assignment, AssignmentService } from '../assignment/assignment'; import { InMemoryAssignmentFilter } from '../assignment/assignment-filter'; -import { - AmplitudeAssignmentService, - FLAG_TYPE_HOLDOUT_GROUP, - FLAG_TYPE_MUTUAL_EXCLUSION_GROUP, -} from '../assignment/assignment-service'; +import { AmplitudeAssignmentService } from '../assignment/assignment-service'; import { FetchHttpClient } from '../transport/http'; import { AssignmentConfig, @@ -15,12 +15,17 @@ import { LocalEvaluationConfig, LocalEvaluationDefaults, } from '../types/config'; -import { FlagConfig, FlagConfigCache } from '../types/flag'; +import { FlagConfigCache } from '../types/flag'; import { HttpClient } from '../types/transport'; import { ExperimentUser } from '../types/user'; -import { Results, Variants } from '../types/variant'; +import { Variant, Variants } from '../types/variant'; import { ConsoleLogger } from '../util/logger'; import { Logger } from '../util/logger'; +import { convertUserToEvaluationContext } from '../util/user'; +import { + evaluationVariantsToVariants, + filterDefaultVariants, +} from '../util/variant'; import { InMemoryFlagConfigCache } from './cache'; import { FlagConfigFetcher } from './fetcher'; @@ -34,22 +39,20 @@ export class LocalEvaluationClient { private readonly logger: Logger; private readonly config: LocalEvaluationConfig; private readonly poller: FlagConfigPoller; - private flags: FlagConfig[]; private readonly assignmentService: AssignmentService; + private readonly evaluation: EvaluationEngine; /** * Directly access the client's flag config cache. * * Used for directly manipulating the flag configs used for evaluation. */ - public readonly cache: FlagConfigCache; + public readonly cache: InMemoryFlagConfigCache; constructor( apiKey: string, config: LocalEvaluationConfig, - flagConfigCache: FlagConfigCache = new InMemoryFlagConfigCache( - config?.bootstrap, - ), + flagConfigCache?: FlagConfigCache, httpClient: HttpClient = new FetchHttpClient(config?.httpAgent), ) { this.config = { ...LocalEvaluationDefaults, ...config }; @@ -59,11 +62,10 @@ export class LocalEvaluationClient { this.config.serverUrl, this.config.debug, ); - // We no longer use the flag config cache for accessing variants. - fetcher.setRawReceiver((flags: string) => { - this.flags = JSON.parse(flags); - }); - this.cache = flagConfigCache; + this.cache = new InMemoryFlagConfigCache( + flagConfigCache, + this.config.bootstrap, + ); this.logger = new ConsoleLogger(this.config.debug); this.poller = new FlagConfigPoller( fetcher, @@ -80,6 +82,7 @@ export class LocalEvaluationClient { this.config.assignmentConfig, ); } + this.evaluation = new EvaluationEngine(); } private createAssignmentService( @@ -94,6 +97,36 @@ export class LocalEvaluationClient { ); } + /** + * Locally evaluate varints 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. + * + * Unlike {@link evaluate}, this function returns a default variant object + * if the flag or experiment was evaluated, but the user was not assigned a + * variant (i.e. 'off'). + * + * @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 evaluateV2( + user: ExperimentUser, + flagKeys?: string[], + ): Record { + const flags = this.cache.getAllCached() as Record; + this.logger.debug('[Experiment] evaluate - user:', user, 'flags:', flags); + const context = convertUserToEvaluationContext(user); + const sortedFlags = topologicalSort(flags, flagKeys); + const results = this.evaluation.evaluate(context, sortedFlags); + void this.assignmentService?.track(new Assignment(user, results)); + this.logger.debug('[Experiment] evaluate - variants: ', results); + return evaluationVariantsToVariants(results); + } + /** * Locally evaluates flag variants for a user. * @@ -105,41 +138,14 @@ export class LocalEvaluationClient { * @param flagKeys The flags to evaluate with the user. If empty, all flags * from the flag cache are evaluated. * @returns The evaluated variants + * @deprecated use evaluateV2 instead */ public async evaluate( user: ExperimentUser, flagKeys?: string[], ): Promise { - this.logger.debug( - '[Experiment] evaluate - user:', - user, - 'flags:', - this.flags, - ); - const results: Results = evaluation.evaluate(this.flags, user); - const assignmentResults: Results = {}; - const variants: Variants = {}; - const filter = flagKeys && flagKeys.length > 0; - for (const flagKey in results) { - const included = !filter || flagKeys.includes(flagKey); - if (included) { - const flagResult = results[flagKey]; - variants[flagKey] = { - value: flagResult.value, - payload: flagResult.payload, - }; - } - if ( - included || - results[flagKey].type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP || - results[flagKey].type == FLAG_TYPE_HOLDOUT_GROUP - ) { - assignmentResults[flagKey] = results[flagKey]; - } - } - void this.assignmentService?.track(new Assignment(user, assignmentResults)); - this.logger.debug('[Experiment] evaluate - variants: ', variants); - return variants; + const results = this.evaluateV2(user, flagKeys); + return filterDefaultVariants(results); } /** diff --git a/packages/node/src/local/fetcher.ts b/packages/node/src/local/fetcher.ts index e5a8d4f..ae4b32e 100644 --- a/packages/node/src/local/fetcher.ts +++ b/packages/node/src/local/fetcher.ts @@ -1,4 +1,7 @@ +import { FlagApi, SdkFlagApi } from '@amplitude/experiment-core'; + import { version as PACKAGE_VERSION } from '../../gen/version'; +import { WrapperClient } from '../transport/http'; import { LocalEvaluationDefaults } from '../types/config'; import { FlagConfig } from '../types/flag'; import { HttpClient } from '../types/transport'; @@ -12,7 +15,7 @@ export class FlagConfigFetcher { private readonly apiKey: string; private readonly serverUrl: string; - private readonly httpClient: HttpClient; + private readonly flagApi: FlagApi; private receiver: (string) => void; @@ -24,7 +27,11 @@ export class FlagConfigFetcher { ) { this.apiKey = apiKey; this.serverUrl = serverUrl; - this.httpClient = httpClient; + this.flagApi = new SdkFlagApi( + apiKey, + serverUrl, + new WrapperClient(httpClient), + ); this.logger = new ConsoleLogger(debug); } @@ -36,45 +43,19 @@ export class FlagConfigFetcher { * environment */ public async fetch(): Promise> { - const endpoint = `${this.serverUrl}/sdk/v1/flags`; - const headers = { - Authorization: `Api-Key ${this.apiKey}`, - Accept: 'application/json', - 'X-Amp-Exp-Library': `experiment-node-server/${PACKAGE_VERSION}`, - 'Content-Type': 'application/json;charset=utf-8', - }; - const body = null; - this.logger.debug('[Experiment] Get flag configs'); - const response = await this.httpClient.request( - endpoint, - 'GET', - headers, - body, - FLAG_CONFIG_TIMEOUT, - ); - if (response.status !== 200) { - throw Error( - `flagConfigs - received error response: ${response.status}: ${response.body}`, - ); - } - this.logger.debug(`[Experiment] Got flag configs: ${response.body}`); + const flags = this.flagApi.getFlags({ + libraryName: 'experiment-node-server', + libraryVersion: PACKAGE_VERSION, + evaluationMode: 'local', + timeoutMillis: FLAG_CONFIG_TIMEOUT, + }); if (this.receiver) { - this.receiver(response.body); + this.receiver(JSON.stringify(flags)); } - return this.parse(response.body); + return flags; } public setRawReceiver(rawReceiver: (flags: string) => void): void { this.receiver = rawReceiver; } - - private parse(flagConfigs: string): Record { - const flagConfigsArray = JSON.parse(flagConfigs); - const flagConfigsRecord: Record = {}; - for (let i = 0; i < flagConfigsArray.length; i++) { - const flagConfig = flagConfigsArray[i]; - flagConfigsRecord[flagConfig.flagKey] = flagConfig; - } - return flagConfigsRecord; - } } diff --git a/packages/node/src/remote/client.ts b/packages/node/src/remote/client.ts index dfcbaac..4288364 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -1,15 +1,20 @@ +import { EvaluationApi, SdkEvaluationApi } from '@amplitude/experiment-core'; + import { version as PACKAGE_VERSION } from '../../gen/version'; -import { FetchHttpClient } from '../transport/http'; +import { FetchHttpClient, WrapperClient } from '../transport/http'; import { ExperimentConfig, RemoteEvaluationDefaults, RemoteEvaluationConfig, } from '../types/config'; import { FetchOptions } from '../types/fetch'; -import { HttpClient } from '../types/transport'; import { ExperimentUser } from '../types/user'; import { Variant, Variants } from '../types/variant'; import { sleep } from '../util/time'; +import { + evaluationVariantsToVariants, + filterDefaultVariants, +} from '../util/variant'; /** * Experiment client for fetching variants for a user remotely. @@ -17,8 +22,8 @@ import { sleep } from '../util/time'; */ export class RemoteEvaluationClient { private readonly apiKey: string; - private readonly httpClient: HttpClient; private readonly config: RemoteEvaluationConfig; + private readonly evaluationApi: EvaluationApi; /** * Creates a new RemoteEvaluationClient instance. @@ -29,38 +34,29 @@ export class RemoteEvaluationClient { public constructor(apiKey: string, config: RemoteEvaluationConfig) { this.apiKey = apiKey; this.config = { ...RemoteEvaluationDefaults, ...config }; - this.httpClient = new FetchHttpClient(config?.httpAgent); + this.evaluationApi = new SdkEvaluationApi( + apiKey, + this.config.serverUrl, + new WrapperClient(new FetchHttpClient(this.config?.httpAgent)), + ); } /** - * Fetch all variants for a user. + * Fetch remote evaluated variants for a user. This function can + * automatically retry the request on failure (if configured), and will + * throw the original error if all retries fail. * - * This method will automatically retry if configured (default). + * Unlike {@link fetch}, this function returns a default variant object + * if the flag or experiment was evaluated, but the user was not assigned a + * variant (i.e. 'off'). * - * @param user The {@link ExperimentUser} context - * @param options The {@link FetchOptions} for this specific fetch request. - * @return The {@link Variants} for the user on success, empty - * {@link Variants} on error. + * @param user The user to fetch variants for. + * @param options Options to configure the fetch request. */ - public async fetch( + public async fetchV2( user: ExperimentUser, options?: FetchOptions, - ): Promise { - if (!this.apiKey) { - throw Error('Experiment API key is empty'); - } - try { - return await this.fetchInternal(user, options); - } catch (e) { - console.error('[Experiment] Failed to fetch variants: ', e); - return {}; - } - } - - private async fetchInternal( - user: ExperimentUser, - options?: FetchOptions, - ): Promise { + ): Promise> { if (!this.apiKey) { throw Error('Experiment API key is empty'); } @@ -78,48 +74,48 @@ export class RemoteEvaluationClient { } } + /** + * Fetch all variants for a user. + * + * This method will automatically retry if configured (default). + * + * @param user The {@link ExperimentUser} context + * @param options The {@link FetchOptions} for this specific fetch request. + * @return The {@link Variants} for the user on success, empty + * {@link Variants} on error. + * @deprecated use fetchV2 instead + */ + public async fetch( + user: ExperimentUser, + options?: FetchOptions, + ): Promise { + try { + const results = await this.fetchV2(user, options); + return filterDefaultVariants(results); + } catch (e) { + console.error('[Experiment] Failed to fetch variants: ', e); + return {}; + } + } + private async doFetch( user: ExperimentUser, timeoutMillis: number, options?: FetchOptions, - ): Promise { + ): Promise> { const userContext = this.addContext(user || {}); - const endpoint = `${this.config.serverUrl}/sdk/vardata`; - const encodedUser = Buffer.from(JSON.stringify(userContext)).toString( - 'base64', - ); - const headers = { - Authorization: `Api-Key ${this.apiKey}`, - 'X-Amp-Exp-User': encodedUser, - }; - if (options && options.flagKeys) { - headers['X-Amp-Exp-Flag-Keys'] = Buffer.from( - JSON.stringify(options.flagKeys), - ).toString('base64url'); - } - this.debug('[Experiment] Fetch variants for user: ', userContext); - const response = await this.httpClient.request( - endpoint, - 'GET', - headers, - null, - timeoutMillis, - ); - if (response.status !== 200) { - throw Error( - `fetch - received error response: ${response.status}: ${response.body}`, - ); - } - const json = JSON.parse(response.body); - const variants = this.parseJsonVariants(json); - this.debug('[Experiment] Fetched variants: ', variants); - return variants; + const results = await this.evaluationApi.getVariants(userContext, { + flagKeys: options?.flagKeys, + timeoutMillis: timeoutMillis, + }); + this.debug('[Experiment] Fetched variants: ', results); + return evaluationVariantsToVariants(results); } private async retryFetch( user: ExperimentUser, options?: FetchOptions, - ): Promise { + ): Promise> { if (this.config.fetchRetries == 0) { return {}; } @@ -146,25 +142,6 @@ export class RemoteEvaluationClient { throw err; } - private async parseJsonVariants(json: string): Promise { - const variants: Variants = {}; - for (const key of Object.keys(json)) { - let value: string; - if ('value' in json[key]) { - value = json[key].value; - } else if ('key' in json[key]) { - // value was previously under the "key" field - value = json[key].key; - } - const variant: Variant = { - value, - payload: json[key].payload, - }; - variants[key] = variant; - } - return variants; - } - private addContext(user: ExperimentUser): ExperimentUser { return { library: `experiment-node-server/${PACKAGE_VERSION}`, diff --git a/packages/node/src/transport/http.ts b/packages/node/src/transport/http.ts index c68c1c4..468bb6d 100644 --- a/packages/node/src/transport/http.ts +++ b/packages/node/src/transport/http.ts @@ -2,6 +2,12 @@ import http from 'http'; import https from 'https'; import url from 'url'; +import { + HttpClient as CoreHttpClient, + HttpRequest, + HttpResponse, +} from '@amplitude/experiment-core'; + import { SimpleResponse, HttpClient } from '../types/transport'; const defaultHttpAgent = new https.Agent({ @@ -13,6 +19,7 @@ export class FetchHttpClient implements HttpClient { constructor(httpAgent: https.Agent) { this.httpAgent = httpAgent || defaultHttpAgent; } + /** * Wraps the http and https libraries in a fetch()-like interface * @param requestUrl @@ -77,3 +84,24 @@ export class FetchHttpClient implements HttpClient { }); } } + +/** + * Wrap the exposed HttpClient in a CoreClient implementation to work with + * FlagApi and EvaluationApi. + */ +export class WrapperClient implements CoreHttpClient { + private readonly client: HttpClient; + constructor(client: HttpClient) { + this.client = client; + } + + async request(request: HttpRequest): Promise { + return await this.client.request( + request.requestUrl, + request.method, + request.headers, + null, + request.timeoutMillis, + ); + } +} diff --git a/packages/node/src/types/config.ts b/packages/node/src/types/config.ts index c69e9f3..65b20ad 100644 --- a/packages/node/src/types/config.ts +++ b/packages/node/src/types/config.ts @@ -2,8 +2,6 @@ import https from 'https'; import { NodeOptions } from '@amplitude/analytics-types'; -import { LocalEvaluationClient } from '..'; - import { FlagConfig } from './flag'; /** diff --git a/packages/node/src/types/flag.ts b/packages/node/src/types/flag.ts index 26ee785..67b68bf 100644 --- a/packages/node/src/types/flag.ts +++ b/packages/node/src/types/flag.ts @@ -1,9 +1,11 @@ +import { EvaluationFlag } from '@amplitude/experiment-core'; + /** * Useful for clarity over functionality. Flag configs are JSON objects that * should not need to be inspected or modified after being received from the * server. */ -export type FlagConfig = Record; +export type FlagConfig = Record | EvaluationFlag; /** * Used to store flag configurations for use in local evaluations. diff --git a/packages/node/src/types/variant.ts b/packages/node/src/types/variant.ts index c2eb8c8..d768c1b 100644 --- a/packages/node/src/types/variant.ts +++ b/packages/node/src/types/variant.ts @@ -3,15 +3,25 @@ */ export type Variant = { /** - * The value of the variant determined by the flag configuration + * The key of the variant. */ - value: string; + key?: string; + /** + * The value of the variant. + */ + value?: string; /** - * The attached payload, if any + * The attached payload, if any. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any payload?: any; + + /** + * Flag, segment, and variant metadata produced as a result of + * evaluation for the user. Used for system purposes. + */ + metadata?: Record; }; /** @@ -20,16 +30,3 @@ export type Variant = { export type Variants = { [flagKey: string]: Variant; }; - -export type FlagResult = { - value: string; - payload: any | null | undefined; - isDefaultVariant: boolean; - expKey: string | null | undefined; - deployed: boolean; - type: string; -}; - -export type Results = { - [flagKey: string]: FlagResult; -}; diff --git a/packages/node/src/util/user.ts b/packages/node/src/util/user.ts new file mode 100644 index 0000000..b6eda89 --- /dev/null +++ b/packages/node/src/util/user.ts @@ -0,0 +1,41 @@ +import { ExperimentUser } from '../types/user'; + +export const convertUserToEvaluationContext = ( + user: ExperimentUser | undefined, +): Record => { + if (!user) { + return {}; + } + const userGroups = user.groups; + const userGroupProperties = user.group_properties; + const context: Record = {}; + user = { ...user }; + delete user['groups']; + delete user['group_properties']; + if (Object.keys(user).length > 0) { + context['user'] = user; + } + const groups: Record> = {}; + if (!userGroups) { + return context; + } + for (const groupType of Object.keys(userGroups)) { + const groupNames = userGroups[groupType]; + if (groupNames.length > 0 && groupNames[0]) { + const groupName = groupNames[0]; + const groupNameMap: Record = { + group_name: groupName, + }; + // Check for group properties + const groupProperties = userGroupProperties?.[groupType]?.[groupName]; + if (groupProperties && Object.keys(groupProperties).length > 0) { + groupNameMap['group_properties'] = groupProperties; + } + groups[groupType] = groupNameMap; + } + } + if (Object.keys(groups).length > 0) { + context['groups'] = groups; + } + return context; +}; diff --git a/packages/node/src/util/variant.ts b/packages/node/src/util/variant.ts new file mode 100644 index 0000000..7656eef --- /dev/null +++ b/packages/node/src/util/variant.ts @@ -0,0 +1,46 @@ +import { EvaluationVariant } from '@amplitude/experiment-core'; + +import { Variant, Variants } from '../types/variant'; + +export const filterDefaultVariants = ( + variants: Record, +): Record => { + const results = {}; + for (const flagKey in variants) { + const variant = variants[flagKey]; + const isDefault = variant?.metadata?.default; + const isDeployed = variant?.metadata?.deployed || true; + if (!isDefault && isDeployed) { + results[flagKey] = variant; + } + } + return results; +}; + +export const evaluationVariantToVariant = ( + evaluationVariant: EvaluationVariant, +): Variant => { + let stringValue: string | undefined; + if (typeof evaluationVariant.value === 'string') { + stringValue = evaluationVariant.value; + } else if ( + evaluationVariant.value !== null && + evaluationVariant.value !== undefined + ) { + stringValue = JSON.stringify(evaluationVariant.value); + } + return { + ...evaluationVariant, + value: stringValue, + }; +}; + +export const evaluationVariantsToVariants = ( + evaluationVariants: Record, +): Variants => { + const variants: Variants = {}; + Object.keys(evaluationVariants).forEach((key) => { + variants[key] = evaluationVariantToVariant(evaluationVariants[key]); + }); + return variants; +}; diff --git a/packages/node/test/local/assignment/assignment-filter.test.ts b/packages/node/test/local/assignment/assignment-filter.test.ts index 1b03e19..9b99c89 100644 --- a/packages/node/test/local/assignment/assignment-filter.test.ts +++ b/packages/node/test/local/assignment/assignment-filter.test.ts @@ -1,22 +1,14 @@ -import { Assignment } from '../../../../node/src/assignment/assignment'; -import { InMemoryAssignmentFilter } from '../../../../node/src/assignment/assignment-filter'; -import { ExperimentUser } from '../../../../node/src/types/user'; -import { sleep } from '../../../../node/src/util/time'; +import { Assignment } from 'src/assignment/assignment'; +import { InMemoryAssignmentFilter } from 'src/assignment/assignment-filter'; +import { ExperimentUser } from 'src/types/user'; +import { sleep } from 'src/util/time'; test('filter - single assignment', async () => { const user: ExperimentUser = { user_id: 'user' }; - const results = {}; - results['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; - results['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, - }; - const filter = new InMemoryAssignmentFilter(100); const assignment = new Assignment(user, results); expect(filter.shouldTrack(assignment)).toEqual(true); @@ -24,18 +16,10 @@ test('filter - single assignment', async () => { test('filter - duplicate assignment', async () => { const user: ExperimentUser = { user_id: 'user' }; - const results = {}; - results['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, - }; - results['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; - const filter = new InMemoryAssignmentFilter(100); const assignment1 = new Assignment(user, results); const assignment2 = new Assignment(user, results); @@ -45,28 +29,14 @@ test('filter - duplicate assignment', async () => { test('filter - same user different results', async () => { const user: ExperimentUser = { user_id: 'user' }; - const results1 = {}; - results1['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, - }; - results1['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results1 = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; - const results2 = {}; - results2['flag-key-1'] = { - value: 'control', - description: 'description-1', - isDefaultVariant: false, - }; - results2['flag-key-2'] = { - value: 'on', - description: 'description-2', - isDefaultVariant: true, + const results2 = { + 'flag-key-1': { key: 'control', value: 'control' }, + 'flag-key-2': { key: 'on', value: 'on' }, }; const filter = new InMemoryAssignmentFilter(100); @@ -79,16 +49,9 @@ test('filter - same user different results', async () => { test('filter - same result different user', async () => { const user1: ExperimentUser = { user_id: 'user' }; const user2: ExperimentUser = { user_id: 'different-user' }; - const results = {}; - results['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, - }; - results['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; const filter = new InMemoryAssignmentFilter(100); @@ -113,24 +76,14 @@ test('filter - empty result', async () => { test('filter - duplicate assignments with different result ordering', async () => { const user: ExperimentUser = { user_id: 'user' }; - const result1 = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, + const results1 = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; - const result2 = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results2 = { + 'flag-key-2': { key: 'control', value: 'control' }, + 'flag-key-1': { key: 'on', value: 'on' }, }; - - const results1 = {}; - const results2 = {}; - results1['flag-key-1'] = result1; - results1['flag-key-2'] = result2; - results2['flag-key-2'] = result2; - results2['flag-key-1'] = result1; - const filter = new InMemoryAssignmentFilter(100); const assignment1 = new Assignment(user, results1); const assignment2 = new Assignment(user, results2); @@ -142,16 +95,9 @@ test('filter - lru replacement', async () => { const user1: ExperimentUser = { user_id: 'user1' }; const user2: ExperimentUser = { user_id: 'user2' }; const user3: ExperimentUser = { user_id: 'user3' }; - const results = {}; - results['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, - }; - results['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; const filter = new InMemoryAssignmentFilter(2); @@ -167,16 +113,9 @@ test('filter - lru replacement', async () => { test('filter - ttl-based eviction', async () => { const user1: ExperimentUser = { user_id: 'user' }; const user2: ExperimentUser = { user_id: 'different-user' }; - const results = {}; - results['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, - }; - results['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, }; const filter = new InMemoryAssignmentFilter(100, 1000); diff --git a/packages/node/test/local/assignment/assignment-service.test.ts b/packages/node/test/local/assignment/assignment-service.test.ts index f6b72e0..323f7e4 100644 --- a/packages/node/test/local/assignment/assignment-service.test.ts +++ b/packages/node/test/local/assignment/assignment-service.test.ts @@ -1,15 +1,12 @@ import * as amplitude from '@amplitude/analytics-node'; - -import { - Assignment, - AssignmentFilter, -} from '../../../../node/src/assignment/assignment'; +import { Assignment, AssignmentFilter } from 'src/assignment/assignment'; import { AmplitudeAssignmentService, DAY_MILLIS, -} from '../../../../node/src/assignment/assignment-service'; -import { ExperimentUser } from '../../../../node/src/types/user'; -import { hashCode } from '../../../../node/src/util/hash'; + toEvent, +} from 'src/assignment/assignment-service'; +import { ExperimentUser } from 'src/types/user'; +import { hashCode } from 'src/util/hash'; const testFilter: AssignmentFilter = { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -22,33 +19,115 @@ const instance = amplitude.createInstance(); const service = new AmplitudeAssignmentService(instance, testFilter); test('assignment to event as expected', async () => { const user: ExperimentUser = { user_id: 'user', device_id: 'device' }; - const results = {}; - results['flag-key-1'] = { - value: 'on', - description: 'description-1', - isDefaultVariant: false, - }; - results['flag-key-2'] = { - value: 'control', - description: 'description-2', - isDefaultVariant: true, + const results = { + basic: { + key: 'control', + value: 'control', + metadata: { + segmentName: 'All Other Users', + flagType: 'experiment', + flagVersion: 10, + default: false, + }, + }, + different_value: { + key: 'on', + value: 'control', + metadata: { + segmentName: 'All Other Users', + flagType: 'experiment', + flagVersion: 10, + default: false, + }, + }, + default: { + key: 'off', + metadata: { + segmentName: 'All Other Users', + flagType: 'experiment', + flagVersion: 10, + default: true, + }, + }, + mutex: { + key: 'slot-1', + value: 'slot-1', + metadata: { + segmentName: 'All Other Users', + flagType: 'mutual-exclusion-group', + flagVersion: 10, + default: false, + }, + }, + holdout: { + key: 'holdout', + value: 'holdout', + metadata: { + segmentName: 'All Other Users', + flagType: 'holdout-group', + flagVersion: 10, + default: false, + }, + }, + partial_metadata: { + key: 'on', + value: 'on', + metadata: { + segmentName: 'All Other Users', + flagType: 'release', + }, + }, + empty_metadata: { + key: 'on', + value: 'on', + }, + empty_variant: {}, }; const assignment = new Assignment(user, results); - const instance = amplitude.createInstance(); - const service = new AmplitudeAssignmentService(instance, testFilter); - const event = service.toEvent(assignment); + const event = toEvent(assignment); expect(event.user_id).toEqual(user.user_id); expect(event.device_id).toEqual(user.device_id); expect(event.event_type).toEqual('[Experiment] Assignment'); + // Event Properties const eventProperties = event.event_properties; - expect(Object.keys(eventProperties).length).toEqual(2); - expect(eventProperties['flag-key-1.variant']).toEqual('on'); - expect(eventProperties['flag-key-2.variant']).toEqual('control'); + expect(eventProperties['basic.variant']).toEqual('control'); + expect(eventProperties['basic.details']).toEqual('v10 rule:All Other Users'); + expect(eventProperties['different_value.variant']).toEqual('on'); + expect(eventProperties['different_value.details']).toEqual( + 'v10 rule:All Other Users', + ); + expect(eventProperties['default.variant']).toEqual('off'); + expect(eventProperties['default.details']).toEqual( + 'v10 rule:All Other Users', + ); + expect(eventProperties['mutex.variant']).toEqual('slot-1'); + expect(eventProperties['default.details']).toEqual( + 'v10 rule:All Other Users', + ); + expect(eventProperties['holdout.variant']).toEqual('holdout'); + expect(eventProperties['holdout.details']).toEqual( + 'v10 rule:All Other Users', + ); + expect(eventProperties['partial_metadata.variant']).toEqual('on'); + expect(eventProperties['partial_metadata.details']).toBeUndefined(); + expect(eventProperties['empty_metadata.variant']).toEqual('on'); + expect(eventProperties['empty_metadata.details']).toBeUndefined(); + // User properties const userProperties = event.user_properties; expect(Object.keys(userProperties).length).toEqual(2); - expect(Object.keys(userProperties['$set']).length).toEqual(1); - expect(Object.keys(userProperties['$unset']).length).toEqual(1); - const canonicalization = 'user device flag-key-1 on flag-key-2 control '; + const setProperties = userProperties['$set']; + expect(Object.keys(setProperties).length).toEqual(5); + expect(setProperties['[Experiment] basic']).toEqual('control'); + expect(setProperties['[Experiment] different_value']).toEqual('on'); + expect(setProperties['[Experiment] holdout']).toEqual('holdout'); + expect(setProperties['[Experiment] partial_metadata']).toEqual('on'); + expect(setProperties['[Experiment] empty_metadata']).toEqual('on'); + const unsetProperties = userProperties['$unset']; + expect(Object.keys(unsetProperties).length).toEqual(1); + expect(unsetProperties['[Experiment] default']).toEqual('-'); + + const canonicalization = + 'user device basic control default off different_value on empty_metadata on holdout holdout mutex slot-1 partial_metadata on '; const expected = `user device ${hashCode(canonicalization)} ${Math.floor( assignment.timestamp / DAY_MILLIS, )}`; diff --git a/packages/node/test/local/client.test.ts b/packages/node/test/local/client.test.ts index 37daafc..c193701 100644 --- a/packages/node/test/local/client.test.ts +++ b/packages/node/test/local/client.test.ts @@ -1,13 +1,11 @@ import { Experiment } from 'src/factory'; -import { LocalEvaluationClient } from 'src/local/client'; import { ExperimentUser } from 'src/types/user'; -import { sleep } from 'src/util/time'; const apiKey = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'; const testUser: ExperimentUser = { user_id: 'test_user' }; -const client = Experiment.initializeLocal(apiKey, { debug: false }); +const client = Experiment.initializeLocal(apiKey); beforeAll(async () => { await client.start(); @@ -20,7 +18,9 @@ afterAll(async () => { test('ExperimentClient.evaluate all flags, success', async () => { const variants = await client.evaluate(testUser); const variant = variants['sdk-local-evaluation-ci-test']; - expect(variant).toEqual({ value: 'on', payload: 'payload' }); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); }); test('ExperimentClient.evaluate one flag, success', async () => { @@ -28,7 +28,9 @@ test('ExperimentClient.evaluate one flag, success', async () => { 'sdk-local-evaluation-ci-test', ]); const variant = variants['sdk-local-evaluation-ci-test']; - expect(variant).toEqual({ value: 'on', payload: 'payload' }); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); }); test('ExperimentClient.evaluate with dependencies, no flag keys, success', async () => { @@ -37,7 +39,8 @@ test('ExperimentClient.evaluate with dependencies, no flag keys, success', async device_id: 'device_id', }); const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant).toEqual({ value: 'control', payload: null }); + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); }); test('ExperimentClient.evaluate with dependencies, with flag keys, success', async () => { @@ -49,7 +52,8 @@ test('ExperimentClient.evaluate with dependencies, with flag keys, success', asy ['sdk-ci-local-dependencies-test'], ); const variant = variants['sdk-ci-local-dependencies-test']; - expect(variant).toEqual({ value: 'control', payload: null }); + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); }); test('ExperimentClient.evaluate with dependencies, with unknown flag keys, no variant', async () => { @@ -72,6 +76,74 @@ test('ExperimentClient.evaluate with dependencies, variant held out', async () = const variant = variants['sdk-ci-local-dependencies-test-holdout']; expect(variant).toBeUndefined(); expect( - client.cache.get('sdk-ci-local-dependencies-test-holdout'), + await client.cache.get('sdk-ci-local-dependencies-test-holdout'), + ).toBeDefined(); +}); + +// Evaluate V2 + +test('ExperimentClient.evaluateV2 all flags, success', async () => { + const variants = await client.evaluateV2(testUser); + const variant = variants['sdk-local-evaluation-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); +}); + +test('ExperimentClient.evaluateV2 one flag, success', async () => { + const variants = await client.evaluateV2(testUser, [ + 'sdk-local-evaluation-ci-test', + ]); + const variant = variants['sdk-local-evaluation-ci-test']; + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('on'); + expect(variant.payload).toEqual('payload'); +}); + +test('ExperimentClient.evaluateV2 with dependencies, no flag keys, success', async () => { + const variants = await client.evaluateV2({ + user_id: 'user_id', + device_id: 'device_id', + }); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); +}); + +test('ExperimentClient.evaluateV2 with dependencies, with flag keys, success', async () => { + const variants = await client.evaluateV2( + { + user_id: 'user_id', + device_id: 'device_id', + }, + ['sdk-ci-local-dependencies-test'], + ); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant.key).toEqual('control'); + expect(variant.value).toEqual('control'); +}); + +test('ExperimentClient.evaluateV2 with dependencies, with unknown flag keys, no variant', async () => { + const variants = await client.evaluateV2( + { + user_id: 'user_id', + device_id: 'device_id', + }, + ['does-not-exist'], + ); + const variant = variants['sdk-ci-local-dependencies-test']; + expect(variant).toBeUndefined(); +}); + +test('ExperimentClient.evaluateV2 with dependencies, variant held out', async () => { + const variants = await client.evaluateV2({ + user_id: 'user_id', + device_id: 'device_id', + }); + const variant = variants['sdk-ci-local-dependencies-test-holdout']; + expect(variant.key).toEqual('off'); + expect(variant.value).toBeUndefined(); + expect( + await client.cache.get('sdk-ci-local-dependencies-test-holdout'), ).toBeDefined(); }); diff --git a/packages/node/test/local/flagConfigFetcher.test.ts b/packages/node/test/local/flagConfigFetcher.test.ts index 95628f1..63f3ef3 100644 --- a/packages/node/test/local/flagConfigFetcher.test.ts +++ b/packages/node/test/local/flagConfigFetcher.test.ts @@ -8,7 +8,7 @@ test('FlagConfigFetcher.fetch, success', async () => { const fetcher = new FlagConfigFetcher( apiKey, new MockHttpClient(async () => { - return { status: 200, body: '{}' }; + return { status: 200, body: '[]' }; }), ); try { diff --git a/packages/node/test/remote/client.test.ts b/packages/node/test/remote/client.test.ts index f87d1ea..4c10ad6 100644 --- a/packages/node/test/remote/client.test.ts +++ b/packages/node/test/remote/client.test.ts @@ -9,7 +9,7 @@ test('ExperimentClient.fetch, success', async () => { const client = new RemoteEvaluationClient(API_KEY, {}); const variants = await client.fetch(testUser); const variant = variants['sdk-ci-test']; - expect(variant).toEqual({ value: 'on', payload: 'payload' }); + expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); }); test('ExperimentClient.fetch, no retries, timeout failure', async () => { @@ -28,7 +28,7 @@ test('ExperimentClient.fetch, no retries, timeout failure, retry success', async }); const variants = await client.fetch(testUser); const variant = variants['sdk-ci-test']; - expect(variant).toEqual({ value: 'on', payload: 'payload' }); + expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); }); test('ExperimentClient.fetch, retry once, timeout first then succeed with 0 backoff', async () => { @@ -40,13 +40,19 @@ test('ExperimentClient.fetch, retry once, timeout first then succeed with 0 back }); const variants = await client.fetch(testUser); const variant = variants['sdk-ci-test']; - expect(variant).toEqual({ value: 'on', payload: 'payload' }); + expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); }); -test('ExperimentClient.fetch, with flag keys options, success', async () => { +test('ExperimentClient.fetch, v1 off returns undefined', async () => { const client = new RemoteEvaluationClient(API_KEY, {}); - const variants = await client.fetch(testUser, { flagKeys: ['sdk-ci-test'] }); - expect(variants).toEqual({ - 'sdk-ci-test': { value: 'on', payload: 'payload' }, - }); + const variant = (await client.fetch({}))['sdk-ci-test']; + expect(variant).toBeUndefined(); +}); + +test('ExperimentClient.fetch, v2 off returns default variant', async () => { + const client = new RemoteEvaluationClient(API_KEY, {}); + const variant = (await client.fetchV2({}))['sdk-ci-test']; + expect(variant.key).toEqual('off'); + expect(variant.value).toBeUndefined(); + expect(variant.metadata.default).toEqual(true); }); diff --git a/packages/node/test/util/user.test.ts b/packages/node/test/util/user.test.ts new file mode 100644 index 0000000..e7bc7cb --- /dev/null +++ b/packages/node/test/util/user.test.ts @@ -0,0 +1,91 @@ +import { ExperimentUser } from 'src/types/user'; +import { convertUserToEvaluationContext } from 'src/util/user'; + +describe('userToEvaluationContext', () => { + test('user, groups and group properties', () => { + const user: ExperimentUser = { + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' }, + groups: { type: ['name'] }, + group_properties: { type: { name: { gk: 'gv' } } }, + }; + const context = convertUserToEvaluationContext(user); + expect(context).toEqual({ + user: { + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' }, + }, + groups: { + type: { + group_name: 'name', + group_properties: { gk: 'gv' }, + }, + }, + }); + }); + test('only user', () => { + const user: ExperimentUser = { + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' }, + }; + const context = convertUserToEvaluationContext(user); + expect(context).toEqual({ + user: { + device_id: 'device_id', + user_id: 'user_id', + country: 'country', + city: 'city', + language: 'language', + platform: 'platform', + version: 'version', + user_properties: { k: 'v' }, + }, + }); + }); + test('only groups and group properties', () => { + const user: ExperimentUser = { + groups: { type: ['name'] }, + group_properties: { type: { name: { gk: 'gv' } } }, + }; + const context = convertUserToEvaluationContext(user); + expect(context).toEqual({ + groups: { + type: { + group_name: 'name', + group_properties: { gk: 'gv' }, + }, + }, + }); + }); + test('only groups', () => { + const user: ExperimentUser = { + groups: { type: ['name'] }, + }; + const context = convertUserToEvaluationContext(user); + expect(context).toEqual({ + groups: { + type: { + group_name: 'name', + }, + }, + }); + }); +}); diff --git a/packages/node/test/util/variant.test.ts b/packages/node/test/util/variant.test.ts new file mode 100644 index 0000000..d9d48ba --- /dev/null +++ b/packages/node/test/util/variant.test.ts @@ -0,0 +1,92 @@ +import { EvaluationVariant } from '@amplitude/experiment-core'; +import { + evaluationVariantsToVariants, + evaluationVariantToVariant, +} from 'src/util/variant'; + +describe('evaluation variant to variant with typed values', () => { + test('string value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + value: 'test', + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('test'); + }); + test('boolean value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + value: true, + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('true'); + }); + test('number value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + value: 1.2, + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('1.2'); + }); + test('array value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + value: [1, 2, 3], + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('[1,2,3]'); + }); + test('object value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + value: { k: 'v' }, + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toEqual('{"k":"v"}'); + }); + test('null value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + value: null, + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toBeUndefined(); + }); + test('undefined value', () => { + const evaluationVariant: EvaluationVariant = { + key: 'on', + }; + const variant = evaluationVariantToVariant(evaluationVariant); + expect(variant.key).toEqual('on'); + expect(variant.value).toBeUndefined(); + }); +}); + +test('test evaluation variants to variants', () => { + const evaluationVariants: Record = { + string: { key: 'on', value: 'test' }, + boolean: { key: 'on', value: true }, + number: { key: 'on', value: 1.2 }, + array: { key: 'on', value: [1, 2, 3] }, + object: { key: 'on', value: { k: 'v' } }, + null: { key: 'on', value: null }, + undefined: { key: 'on' }, + }; + const variants = evaluationVariantsToVariants(evaluationVariants); + expect(variants).toEqual({ + string: { key: 'on', value: 'test' }, + boolean: { key: 'on', value: 'true' }, + number: { key: 'on', value: '1.2' }, + array: { key: 'on', value: '[1,2,3]' }, + object: { key: 'on', value: '{"k":"v"}' }, + null: { key: 'on', value: undefined }, + undefined: { key: 'on' }, + }); +}); diff --git a/yarn.lock b/yarn.lock index d9bda25..5f68587 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36,10 +36,12 @@ resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-1.3.3.tgz#c7b2a21e6ab0eb1670cce4d03127b62c373c6ed4" integrity sha512-V4/h+izhG7NyVfIva1uhe6bToI/l5n+UnEomL3KEO9DkFoKiOG7KmXo/fmzfU6UmD1bUEWmy//hUFF16BfrEww== -"@amplitude/evaluation-js@1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@amplitude/evaluation-js/-/evaluation-js-1.1.1.tgz#b526fe180dc3f60a4720a5cbcccf5d4efb5836c6" - integrity sha512-YWvaWf2zMHFJQOomu1RJT+KzzWrAy7GVbvyCj16gktEUi98yM7QPvEBUMW8jVLKSqi5mdZOMivxjBC3yF171dQ== +"@amplitude/experiment-core@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@amplitude/experiment-core/-/experiment-core-0.7.1.tgz#a1917e15e617cbe626c01138881e99180f26bd35" + integrity sha512-RM0xX4TBVuS/cv2Tu4BG+1AhG7nOUFO6bmklrGoeASB/tdZ+hda28lF1YO+YI7A9b+5+pED8i/vuUrsVuHEkSg== + dependencies: + js-base64 "^3.7.5" "@amplitude/experiment-js-client@^1.7.3": version "1.7.3" @@ -5606,6 +5608,11 @@ jest@^26.6.3: import-local "^3.0.2" jest-cli "^26.6.3" +js-base64@^3.7.5: + version "3.7.5" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca" + integrity sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"