diff --git a/src/ConfigEvaluation.ts b/src/ConfigEvaluation.ts index 2d2f1c1..db33be6 100644 --- a/src/ConfigEvaluation.ts +++ b/src/ConfigEvaluation.ts @@ -7,6 +7,7 @@ export default class ConfigEvaluation { public config_delegate: string | null; public fetch_from_server: boolean; public undelegated_secondary_exposures: Record[] | undefined; + public is_experiment_group: boolean; constructor( value: boolean, @@ -20,7 +21,8 @@ export default class ConfigEvaluation { ) { this.value = value; this.rule_id = rule_id; - if (typeof json_value === 'boolean') { // handle legacy gate case + if (typeof json_value === 'boolean') { + // handle legacy gate case this.json_value = {}; } else { this.json_value = json_value; @@ -30,6 +32,11 @@ export default class ConfigEvaluation { this.config_delegate = config_delegate; this.fetch_from_server = fetch_from_server; this.explicit_parameters = explicit_parameters; + this.is_experiment_group = false; + } + + public setIsExperimentGroup(isExperimentGroup: boolean = false) { + this.is_experiment_group = isExperimentGroup; } public static fetchFromServer() { diff --git a/src/ConfigSpec.ts b/src/ConfigSpec.ts index e07c77b..ce2dc18 100644 --- a/src/ConfigSpec.ts +++ b/src/ConfigSpec.ts @@ -8,6 +8,8 @@ export class ConfigSpec { public rules: ConfigRule[]; public entity: string; public explicitParameters: string[] | null; + public hasSharedParams: boolean; + public isActive?: boolean; constructor(specJSON: Record) { this.name = specJSON.name as string; @@ -19,6 +21,13 @@ export class ConfigSpec { this.rules = this.parseRules(specJSON.rules); this.entity = specJSON.entity as string; this.explicitParameters = specJSON.explicitParameters as string[]; + if (specJSON.isActive !== null) { + this.isActive = specJSON.isActive as boolean; + } + this.hasSharedParams = + specJSON.hasSharedParams != null + ? specJSON.hasSharedParams === true + : false; } parseRules(rulesJSON: unknown) { @@ -41,6 +50,7 @@ export class ConfigRule { public salt: string; public idType: string; public configDelegate: string | null; + public isExperimentGroup?: boolean; constructor(ruleJSON: Record) { this.name = ruleJSON.name as string; @@ -51,6 +61,10 @@ export class ConfigRule { this.salt = ruleJSON.salt as string; this.idType = ruleJSON.idType as string; this.configDelegate = (ruleJSON.configDelegate as string) ?? null; + + if (ruleJSON.isExperimentGroup !== null) { + this.isExperimentGroup = ruleJSON.isExperimentGroup as boolean; + } } parseConditions(conditionsJSON: unknown) { diff --git a/src/Evaluator.ts b/src/Evaluator.ts index 21aae80..b3dc894 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -22,11 +22,20 @@ type InitializeResponse = { rule_id: string; is_device_based: boolean; secondary_exposures: unknown; + is_experiment_active?: boolean; + is_user_in_experiment?: boolean; + is_in_layer?: boolean; + allocated_experiment_name?: string; + explicit_parameters?: string[]; + undelegated_secondary_exposures?: Record[]; }; export default class Evaluator { private gateOverrides: Record>; - private configOverrides: Record>>; + private configOverrides: Record< + string, + Record> + >; private initialized: boolean = false; private store: SpecStore; @@ -181,16 +190,29 @@ export default class Evaluator { const res = this._eval(user, spec); const format = this._specToInitializeResponse(spec, res); if (spec.entity !== 'dynamic_config') { - const userInExperiment = this._isUserAllocatedToExperiment( + format.is_user_in_experiment = this._isUserAllocatedToExperiment( user, spec, ); - const experimentActive = this._isExperimentActive(spec); - // These parameters only control sticky experiments on the client - // @ts-ignore - format.is_experiment_active = experimentActive; - // @ts-ignore - format.is_user_in_experiment = userInExperiment; + format.is_experiment_active = this._isExperimentActive(spec); + if (spec.hasSharedParams) { + format.is_in_layer = true; + format.explicit_parameters = spec.explicitParameters ?? []; + + let layerValue = {}; + const layerName = this.store.getExperimentLayer(spec.name); + if (layerName != null) { + const layer = this.store.getLayer(layerName); + if (layer != null) { + layerValue = layer.defaultValue as object; + } + } + + format.value = { + ...layerValue, + ...(format.value as object), + }; + } } return format; @@ -200,21 +222,24 @@ export default class Evaluator { const layers = Object.entries(this.store.getAllLayers()).map( ([layer, spec]) => { const res = this._eval(user, spec); - const format = this._specToInitializeResponse(spec, res); - if (res.config_delegate != null) { - // @ts-ignore + let format = this._specToInitializeResponse(spec, res); + format.explicit_parameters = spec.explicitParameters ?? []; + if (res.config_delegate != null && res.config_delegate !== '') { + const delegateSpec = this.store.getConfig(res.config_delegate); format.allocated_experiment_name = getHashedName(res.config_delegate); - // @ts-ignore - format.is_experiment_active = true; - // @ts-ignore - format.is_user_in_experiment = true; + + format.is_experiment_active = this._isExperimentActive(delegateSpec); + format.is_user_in_experiment = this._isUserAllocatedToExperiment( + user, + delegateSpec, + ); + format.explicit_parameters = delegateSpec?.explicitParameters ?? []; } - // @ts-ignore - format.explicit_parameters = format.explicit_parameters ?? []; - // @ts-ignore + format.undelegated_secondary_exposures = this._cleanExposures( res.undelegated_secondary_exposures ?? [], ); + return format; }, ); @@ -233,6 +258,7 @@ export default class Evaluator { ), sdkParams: {}, has_updates: true, + generator: 'statsig-node-sdk', time: 0, // set the time to 0 so this doesnt interfere with polling }; } @@ -241,7 +267,7 @@ export default class Evaluator { spec: ConfigSpec, res: ConfigEvaluation, ): InitializeResponse { - const output = { + const output: InitializeResponse = { name: getHashedName(spec.name), value: res.fetch_from_server ? {} : res.json_value, group: res.rule_id, @@ -252,14 +278,15 @@ export default class Evaluator { }; if (res.explicit_parameters) { - // @ts-ignore - output['explicit_parameters'] = res.explicit_parameters; + output.explicit_parameters = res.explicit_parameters; } return output; } - private _cleanExposures(exposures: Record[]): Record[] { + private _cleanExposures( + exposures: Record[], + ): Record[] { const seen: Record = {}; return exposures .map((exposure: Record) => { @@ -277,7 +304,10 @@ export default class Evaluator { this.store.shutdown(); } - _evalConfig(user: StatsigUser, config: ConfigSpec | null): ConfigEvaluation | null { + _evalConfig( + user: StatsigUser, + config: ConfigSpec | null, + ): ConfigEvaluation | null { if (!config) { return null; } @@ -287,7 +317,12 @@ export default class Evaluator { _eval(user: StatsigUser, config: ConfigSpec): ConfigEvaluation { if (!config.enabled) { - return new ConfigEvaluation(false, 'disabled', [], config.defaultValue as Record); + return new ConfigEvaluation( + false, + 'disabled', + [], + config.defaultValue as Record, + ); } let secondary_exposures: Record[] = []; @@ -313,14 +348,18 @@ export default class Evaluator { } const pass = this._evalPassPercent(user, rule, config); - return new ConfigEvaluation( + const evaluation = new ConfigEvaluation( pass, ruleResult.rule_id, secondary_exposures, - pass ? ruleResult.json_value : config.defaultValue as Record, + pass + ? ruleResult.json_value + : (config.defaultValue as Record), config.explicitParameters, ruleResult.config_delegate, ); + evaluation.setIsExperimentGroup(ruleResult.is_experiment_group); + return evaluation; } } @@ -333,7 +372,11 @@ export default class Evaluator { ); } - _evalDelegate(user: StatsigUser, rule: ConfigRule, exposures: Record[]) { + _evalDelegate( + user: StatsigUser, + rule: ConfigRule, + exposures: Record[], + ) { if (rule.configDelegate == null) { return null; } @@ -393,15 +436,20 @@ export default class Evaluator { } } - return new ConfigEvaluation( + const evaluation = new ConfigEvaluation( pass, rule.id, secondaryExposures, rule.returnValue as Record, ); + evaluation.setIsExperimentGroup(rule.isExperimentGroup ?? false); + return evaluation; } - _evalCondition(user: StatsigUser, condition: ConfigCondition): {passes: boolean, fetchFromServer?: boolean, exposures?: any[]} { + _evalCondition( + user: StatsigUser, + condition: ConfigCondition, + ): { passes: boolean; fetchFromServer?: boolean; exposures?: any[] } { let value = null; let field = condition.field; let target = condition.targetValue; @@ -425,7 +473,8 @@ export default class Evaluator { }); return { - passes: condition.type.toLowerCase() === 'fail_gate' ? !value : !!value, + passes: + condition.type.toLowerCase() === 'fail_gate' ? !value : !!value, exposures: allExposures, }; case 'ip_based': @@ -464,47 +513,65 @@ export default class Evaluator { switch (op) { // numerical case 'gt': - evalResult = numberCompare((a: number, b: number) => a > b)(value, target); + evalResult = numberCompare((a: number, b: number) => a > b)( + value, + target, + ); break; case 'gte': - evalResult = numberCompare((a: number, b: number) => a >= b)(value, target); + evalResult = numberCompare((a: number, b: number) => a >= b)( + value, + target, + ); break; case 'lt': - evalResult = numberCompare((a: number, b: number) => a < b)(value, target); + evalResult = numberCompare((a: number, b: number) => a < b)( + value, + target, + ); break; case 'lte': - evalResult = numberCompare((a: number, b: number) => a <= b)(value, target); + evalResult = numberCompare((a: number, b: number) => a <= b)( + value, + target, + ); break; // version case 'version_gt': evalResult = versionCompareHelper((result) => result > 0)( - value, target as string + value, + target as string, ); break; case 'version_gte': evalResult = versionCompareHelper((result) => result >= 0)( - value, target as string + value, + target as string, ); break; case 'version_lt': evalResult = versionCompareHelper((result) => result < 0)( - value, target as string + value, + target as string, ); break; case 'version_lte': evalResult = versionCompareHelper((result) => result <= 0)( - value, target as string + value, + target as string, ); break; case 'version_eq': evalResult = versionCompareHelper((result) => result === 0)( - value, target as string + value, + target as string, ); break; case 'version_neq': evalResult = versionCompareHelper((result) => result !== 0)( - value, target as string + value, + target as string, ); break; @@ -614,32 +681,22 @@ export default class Evaluator { return { passes: evalResult }; } - _isExperimentActive(experimentConfig: ConfigSpec) { - for (const rule of experimentConfig.rules) { - const ruleID = rule['id']; - if (ruleID == null) { - continue; - } - if (ruleID.toLowerCase() === 'layerassignment') { - return true; - } + _isExperimentActive(experimentConfig: ConfigSpec | null) { + if (experimentConfig == null) { + return false; } - return false; + return experimentConfig.isActive === true; } - _isUserAllocatedToExperiment(user: StatsigUser, experimentConfig: ConfigSpec) { - for (const rule of experimentConfig.rules) { - const ruleID = rule['id']; - if (ruleID == null) { - continue; - } - if (ruleID.toLowerCase() === 'layerassignment') { - const evalResult = this._evalRule(user, rule); - // user is in an experiment when they FAIL the layerAssignment rule - return !evalResult.value; - } + _isUserAllocatedToExperiment( + user: StatsigUser, + experimentConfig: ConfigSpec | null, + ) { + if (experimentConfig == null) { + return false; } - return false; + const evalResult = this._eval(user, experimentConfig); + return evalResult.is_experiment_group; } private getFromIP(user: StatsigUser, field: string) { @@ -698,8 +755,8 @@ function getFromUser(user: StatsigUser, field: string): any | null { if (typeof user !== 'object') { return null; } - const indexableUser = user as {[field: string]: unknown}; - + const indexableUser = user as { [field: string]: unknown }; + return ( indexableUser[field] ?? indexableUser[field.toLowerCase()] ?? @@ -745,13 +802,17 @@ function getFromEnvironment(user: StatsigUser, field: string) { ); } -function numberCompare(fn: (a: number, b: number) => boolean): (a: unknown, b: unknown) => boolean { +function numberCompare( + fn: (a: number, b: number) => boolean, +): (a: unknown, b: unknown) => boolean { return (a: unknown, b: unknown) => { return typeof a === 'number' && typeof b === 'number' && fn(a, b); }; } -function versionCompareHelper(fn: (res: number) => boolean): (a: string, b: string) => boolean { +function versionCompareHelper( + fn: (res: number) => boolean, +): (a: string, b: string) => boolean { return (a: string, b: string) => { const comparison = versionCompare(a, b); if (comparison == null) { @@ -810,7 +871,10 @@ function removeVersionExtension(version: string): string { return version; } -function stringCompare(ignoreCase: boolean, fn: (a: string, b: string) => boolean): (a: string, b: string) => boolean { +function stringCompare( + ignoreCase: boolean, + fn: (a: string, b: string) => boolean, +): (a: string, b: string) => boolean { return (a: string, b: string): boolean => { if (a == null || b == null) { return false; @@ -821,7 +885,9 @@ function stringCompare(ignoreCase: boolean, fn: (a: string, b: string) => boolea }; } -function dateCompare(fn: (a: Date, b: Date) => boolean): (a: string, b: string) => boolean { +function dateCompare( + fn: (a: Date, b: Date) => boolean, +): (a: string, b: string) => boolean { return (a: string, b: string): boolean => { if (a == null || b == null) { return false; @@ -847,7 +913,11 @@ function dateCompare(fn: (a: Date, b: Date) => boolean): (a: string, b: string) }; } -function arrayAny(value: any, array: unknown, fn: (value: any, otherValue: any) => boolean): boolean { +function arrayAny( + value: any, + array: unknown, + fn: (value: any, otherValue: any) => boolean, +): boolean { if (!Array.isArray(array)) { return false; } diff --git a/src/SpecStore.ts b/src/SpecStore.ts index fa40a6b..f45b209 100644 --- a/src/SpecStore.ts +++ b/src/SpecStore.ts @@ -20,6 +20,7 @@ export type ConfigStore = { configs: Record; idLists: Record; layers: Record; + experimentToLayer: Record; }; export default class SpecStore { @@ -44,7 +45,13 @@ export default class SpecStore { this.api = options.api; this.rulesUpdatedCallback = options.rulesUpdatedCallback ?? null; this.time = 0; - this.store = { gates: {}, configs: {}, idLists: {}, layers: {} }; + this.store = { + gates: {}, + configs: {}, + idLists: {}, + layers: {}, + experimentToLayer: {}, + }; this.syncInterval = syncInterval; this.idListSyncInterval = idListSyncInterval; this.initialized = false; @@ -77,6 +84,10 @@ export default class SpecStore { return this.store.layers[layerName] ?? null; } + public getExperimentLayer(experimentName: string): string | null { + return this.store.experimentToLayer[experimentName] ?? null; + } + public getIDList(listName: string): IDList | null { return this.store.idLists[listName] ?? null; } @@ -136,9 +147,7 @@ export default class SpecStore { message = e.message; } console.error( - `statsigSDK::sync> Failed while attempting to sync values: ${ - message - }`, + `statsigSDK::sync> Failed while attempting to sync values: ${message}`, ); } @@ -155,12 +164,14 @@ export default class SpecStore { let parseFailed = false; - const updatedGates : Record = {}; - const updatedConfigs : Record = {}; - const updatedLayers : Record = {}; + const updatedGates: Record = {}; + const updatedConfigs: Record = {}; + const updatedLayers: Record = {}; + const updatedExpToLayer: Record = {}; const gateArray = specsJSON?.feature_gates; const configArray = specsJSON?.dynamic_configs; const layersArray = specsJSON?.layer_configs; + const layerToExperimentMap = specsJSON?.layers; if ( !Array.isArray(gateArray) || @@ -200,10 +211,27 @@ export default class SpecStore { } } + if ( + layerToExperimentMap != null && + typeof layerToExperimentMap === 'object' + ) { + for (const [layerName, experiments] of Object.entries( + // @ts-ignore + layerToExperimentMap, + )) { + // @ts-ignore + for (const experimentName of experiments) { + // experiment -> layer is a 1:1 mapping + updatedExpToLayer[experimentName] = layerName; + } + } + } + if (!parseFailed) { this.store.gates = updatedGates; this.store.configs = updatedConfigs; this.store.layers = updatedLayers; + this.store.experimentToLayer = updatedExpToLayer; this.time = (specsJSON.time as number) ?? this.time; } return !parseFailed; @@ -211,12 +239,9 @@ export default class SpecStore { private async _syncIDLists(): Promise { try { - const response = await this.fetcher.post( - this.api + '/get_id_lists', - { - statsigMetadata: getStatsigMetadata(), - }, - ); + const response = await this.fetcher.post(this.api + '/get_id_lists', { + statsigMetadata: getStatsigMetadata(), + }); // @ts-ignore const parsed = await response.json(); let promises = []; diff --git a/src/__tests__/ClientInitializeResponseConsistency.test.ts b/src/__tests__/ClientInitializeResponseConsistency.test.ts index 876f4ec..f1d2f28 100644 --- a/src/__tests__/ClientInitializeResponseConsistency.test.ts +++ b/src/__tests__/ClientInitializeResponseConsistency.test.ts @@ -2,11 +2,13 @@ const fs = require('fs'); const path = require('path'); // @ts-ignore const fetch = require('node-fetch'); +const shajs = require('sha.js'); import * as statsigsdk from '../index'; // @ts-ignore const statsig = statsigsdk.default; +let clientKey = 'client-wlH3WMkysINMhMU8VrNBkbjrEr2JQrqgxKwDPOUosJK'; let secret = process.env.test_api_key; if (!secret) { try { @@ -27,11 +29,10 @@ if (secret) { jest.resetModules(); }); - ['https://api.statsig.com/v1', 'https://staging.api.statsig.com/v1'].map( - (url) => - test(`server and SDK evaluates gates to the same results on ${url}`, async () => { - await _validateInitializeConsistency(url); - }), + ['https://api.statsig.com/v1'].map((url) => + test(`server and SDK evaluates gates to the same results on ${url}`, async () => { + await _validateInitializeConsistency(url); + }), ); }); } else { @@ -48,12 +49,15 @@ if (secret) { async function _validateInitializeConsistency(api) { expect.assertions(1); const user = { - userID: '12345', + userID: '123', email: 'test@statsig.com', country: 'US', custom: { test: '123', }, + customIDs: { + stableID: '12345', + }, }; const response = await fetch(api + '/initialize', { method: 'POST', @@ -66,7 +70,7 @@ async function _validateInitializeConsistency(api) { }), headers: { 'Content-type': 'application/json; charset=UTF-8', - 'STATSIG-API-KEY': 'client-wlH3WMkysINMhMU8VrNBkbjrEr2JQrqgxKwDPOUosJK', + 'STATSIG-API-KEY': clientKey, 'STATSIG-CLIENT-TIME': Date.now(), }, }); @@ -86,9 +90,6 @@ async function _validateInitializeConsistency(api) { delete item.gate; }); } - // // TODO for full layers future proofing - // delete item['explicit_parameters']; - delete item['is_in_layer']; } } @@ -99,14 +100,17 @@ async function _validateInitializeConsistency(api) { for (const topLevel in sdkInitializeResponse) { for (const property in sdkInitializeResponse[topLevel]) { const item = sdkInitializeResponse[topLevel][property]; - if (item.secondary_exposures) { - // initialize has these hashed, we are putting them in plain text - // exposure logging still works - item.secondary_exposures.map((item) => { - delete item.gate; - }); - } + // initialize has these hashed, we are putting them in plain text + // exposure logging still works + item.secondary_exposures?.map((item) => { + delete item.gate; + }); + item.undelegated_secondary_exposures?.map((item) => { + delete item.gate; + }); } } + delete testData.generator; + delete sdkInitializeResponse.generator; expect(sdkInitializeResponse).toEqual(testData); } diff --git a/src/__tests__/StatsigE2ETest.test.ts b/src/__tests__/StatsigE2ETest.test.ts index a20e9a3..f1d2b22 100644 --- a/src/__tests__/StatsigE2ETest.test.ts +++ b/src/__tests__/StatsigE2ETest.test.ts @@ -124,7 +124,7 @@ describe('Verify e2e behavior of the SDK with mocked network', () => { expect(postedLogs.events[0].metadata['gate']).toEqual('always_on_gate'); expect(postedLogs.events[0].metadata['gateValue']).toEqual('true'); expect(postedLogs.events[0].metadata['ruleID']).toEqual( - '6N6Z8ODekNYZ7F8gFdoLP5', + '2DWuOvXQZWKvoaNm27dqcs', ); expect(postedLogs.events[1].eventName).toEqual('statsig::gate_exposure'); @@ -133,7 +133,7 @@ describe('Verify e2e behavior of the SDK with mocked network', () => { ); expect(postedLogs.events[1].metadata['gateValue']).toEqual('true'); expect(postedLogs.events[1].metadata['ruleID']).toEqual( - '7w9rbTSffLT89pxqpyhuqK', + '3jdTW54SQWbbxFFZJe7wYZ', ); expect(postedLogs.events[2].eventName).toEqual('statsig::gate_exposure'); @@ -168,7 +168,7 @@ describe('Verify e2e behavior of the SDK with mocked network', () => { expect(postedLogs.events[0].eventName).toEqual('statsig::config_exposure'); expect(postedLogs.events[0].metadata['config']).toEqual('test_config'); expect(postedLogs.events[0].metadata['ruleID']).toEqual( - '1kNmlB23wylPFZi1M0Divl', + '4lInPNRUnjUzaWNkEWLFA9', ); expect(postedLogs.events[1].eventName).toEqual('statsig::config_exposure'); @@ -182,9 +182,9 @@ describe('Verify e2e behavior of the SDK with mocked network', () => { statsigUser, 'sample_experiment', ); - expect(experiment.get('experiment_param', '')).toEqual('test'); + expect(experiment.get('sample_parameter', true)).toEqual(false); experiment = await statsig.getExperiment(randomUser, 'sample_experiment'); - expect(experiment.get('experiment_param', '')).toEqual('control'); + expect(experiment.get('sample_parameter', false)).toEqual(true); statsig.shutdown(); expect(postedLogs.events.length).toEqual(2); @@ -193,7 +193,7 @@ describe('Verify e2e behavior of the SDK with mocked network', () => { 'sample_experiment', ); expect(postedLogs.events[0].metadata['ruleID']).toEqual( - '2RamGujUou6h2bVNQWhtNZ', + '5yQbPMfmKQdiRV35hS3B2l', ); expect(postedLogs.events[1].eventName).toEqual('statsig::config_exposure'); @@ -201,7 +201,7 @@ describe('Verify e2e behavior of the SDK with mocked network', () => { 'sample_experiment', ); expect(postedLogs.events[1].metadata['ruleID']).toEqual( - '2RamGsERWbWMIMnSfOlQuX', + '5yQbPNUpd8mNbkB0SZZeln', ); }); diff --git a/src/__tests__/download_config_spec.json b/src/__tests__/download_config_spec.json index e032cf9..48e61c7 100644 --- a/src/__tests__/download_config_spec.json +++ b/src/__tests__/download_config_spec.json @@ -37,7 +37,7 @@ { "name": "test_config", "type": "dynamic_config", - "salt": "50ad5c60-9e7a-42ce-86c6-c49035185b14", + "salt": "8708768f-d58d-4055-93b4-0c9bf0d03cb7", "enabled": true, "defaultValue": { "number": 4, @@ -46,8 +46,8 @@ }, "rules": [ { - "name": "1kNmlB23wylPFZi1M0Divl", - "groupName": "statsig email", + "name": "4lInPNRUnjUzaWNkEWLFA9", + "groupName": "statsig emails", "passPercentage": 100, "conditions": [ { @@ -55,7 +55,9 @@ "targetValue": ["@statsig.com"], "operator": "str_contains_any", "field": "email", - "additionalValues": {} + "additionalValues": {}, + "isDeviceBased": false, + "idType": "userID" } ], "returnValue": { @@ -63,23 +65,26 @@ "string": "statsig", "boolean": false }, - "id": "1kNmlB23wylPFZi1M0Divl", - "salt": "f2ac6975-174d-497e-be7f-599fea626132" + "id": "4lInPNRUnjUzaWNkEWLFA9", + "salt": "ffaa584f-c2f0-4ca3-b794-dcb30b27250d", + "isDeviceBased": false, + "idType": "userID" } - ] + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "dynamic_config" }, { "name": "sample_experiment", "type": "dynamic_config", - "salt": "f8aeba58-18fb-4f36-9bbd-4c611447a912", + "salt": "4f0c0147-a2a9-4f21-b618-ec9d8787973c", "enabled": true, - "defaultValue": { - "experiment_param": "control" - }, + "defaultValue": {}, "rules": [ { - "name": "", - "groupName": "experimentSize", + "name": "layerAssignment", + "groupName": "Layer Assignment", "passPercentage": 100, "conditions": [ { @@ -165,20 +170,21 @@ "operator": "none", "field": null, "additionalValues": { - "salt": "00cddb4b-69f5-47c6-aeaa-5bac43cf45a4" - } + "salt": "ed4fd4c3-4bd2-4b3d-891a-4bde19e17170" + }, + "isDeviceBased": false, + "idType": "userID" } ], - "returnValue": { - "experiment_param": "layer_default", - "layer_param": true, - "second_layer_param": false - }, - "id": "", - "salt": "" + "returnValue": {}, + "id": "layerAssignment", + "salt": "", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true }, { - "name": "2RamGsERWbWMIMnSfOlQuX", + "name": "5yQbPMfmKQdiRV35hS3B2l", "groupName": "Control", "passPercentage": 100, "conditions": [ @@ -188,20 +194,23 @@ "operator": "lt", "field": null, "additionalValues": { - "salt": "f8aeba58-18fb-4f36-9bbd-4c611447a912" - } + "salt": "4f0c0147-a2a9-4f21-b618-ec9d8787973c" + }, + "isDeviceBased": false, + "idType": "userID" } ], "returnValue": { - "experiment_param": "control", - "layer_param": true, - "second_layer_param": false + "sample_parameter": false }, - "id": "2RamGsERWbWMIMnSfOlQuX", - "salt": "2RamGsERWbWMIMnSfOlQuX" + "id": "5yQbPMfmKQdiRV35hS3B2l", + "salt": "5yQbPMfmKQdiRV35hS3B2l", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true }, { - "name": "2RamGujUou6h2bVNQWhtNZ", + "name": "5yQbPNUpd8mNbkB0SZZeln", "groupName": "Test", "passPercentage": 100, "conditions": [ @@ -211,33 +220,73 @@ "operator": "lt", "field": null, "additionalValues": { - "salt": "f8aeba58-18fb-4f36-9bbd-4c611447a912" - } + "salt": "4f0c0147-a2a9-4f21-b618-ec9d8787973c" + }, + "isDeviceBased": false, + "idType": "userID" } ], "returnValue": { - "experiment_param": "test", - "layer_param": true, - "second_layer_param": true + "sample_parameter": true }, - "id": "2RamGujUou6h2bVNQWhtNZ", - "salt": "2RamGujUou6h2bVNQWhtNZ" + "id": "5yQbPNUpd8mNbkB0SZZeln", + "salt": "5yQbPNUpd8mNbkB0SZZeln", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true } ], - "explicitParameters": ["experiment_param"] + "isDeviceBased": false, + "idType": "userID", + "entity": "experiment", + "isActive": true + }, + { + "name": "test", + "type": "dynamic_config", + "salt": "00dc0a4b-4901-4214-b6c5-9e45acdb3402", + "enabled": true, + "defaultValue": {}, + "rules": [ + { + "name": "prestart", + "groupName": "Unstarted", + "passPercentage": 100, + "conditions": [ + { + "type": "public", + "targetValue": null, + "operator": null, + "field": null, + "additionalValues": {}, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": {}, + "id": "prestart", + "salt": "", + "isDeviceBased": false, + "idType": "userID" + } + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "experiment", + "isActive": false } ], "feature_gates": [ { "name": "always_on_gate", "type": "feature_gate", - "salt": "47403b4e-7829-43d1-b1ac-3992a5c1b4ac", + "salt": "6e32ca51-c22e-44fb-9654-f5494cdfb2c5", "enabled": true, "defaultValue": false, "rules": [ { - "name": "6N6Z8ODekNYZ7F8gFdoLP5", - "groupName": "everyone", + "name": "2DWuOvXQZWKvoaNm27dqcs", + "groupName": "all", "passPercentage": 100, "conditions": [ { @@ -245,14 +294,21 @@ "targetValue": null, "operator": null, "field": null, - "additionalValues": {} + "additionalValues": {}, + "isDeviceBased": false, + "idType": "userID" } ], "returnValue": true, - "id": "6N6Z8ODekNYZ7F8gFdoLP5", - "salt": "14862979-1468-4e49-9b2a-c8bb100eed8f" + "id": "2DWuOvXQZWKvoaNm27dqcs", + "salt": "bda12dcd-f047-4674-b229-04d0215198c1", + "isDeviceBased": false, + "idType": "userID" } - ] + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "feature_gate" }, { "name": "fetch_from_server_fallback", @@ -275,21 +331,26 @@ } ], "returnValue": true, - "id": "6N6Z8ODekNYZ7F8gFdoLP5", - "salt": "14862979-1468-4e49-9b2a-c8bb100eed8f" + "id": "2DWuOvXQZWKvoaNm27dqcs", + "salt": "bda12dcd-f047-4674-b229-04d0215198c1", + "isDeviceBased": false, + "idType": "userID" } - ] + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "feature_gate" }, { "name": "on_for_statsig_email", "type": "feature_gate", - "salt": "4ab7fc7b-c8a0-4ef1-b869-889467678688", + "salt": "14de8b34-05eb-41b4-81eb-4156f96ba6b4", "enabled": true, "defaultValue": false, "rules": [ { - "name": "7w9rbTSffLT89pxqpyhuqK", - "groupName": "on for statsig emails", + "name": "3jdTW54SQWbbxFFZJe7wYZ", + "groupName": "statsig emails", "passPercentage": 100, "conditions": [ { @@ -297,27 +358,36 @@ "targetValue": ["@statsig.com"], "operator": "str_contains_any", "field": "email", - "additionalValues": {} + "additionalValues": {}, + "isDeviceBased": false, + "idType": "userID" } ], "returnValue": true, - "id": "7w9rbTSffLT89pxqpyhuqK", - "salt": "e452510f-bd5b-42cb-a71e-00498a7903fc" + "id": "3jdTW54SQWbbxFFZJe7wYZ", + "salt": "d9857174-5f55-488d-bb40-649122b21f6b", + "isDeviceBased": false, + "idType": "userID" } - ] + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "feature_gate" } ], + "id_lists": {}, + "layers": { + "statsig::sample_experiment_layer": ["sample_experiment"], + "statsig::test_layer": ["test"], + "d_layer_delegate_to_fallback": ["test_config_fallback"] + }, "layer_configs": [ { - "name": "a_layer", + "name": "statsig::sample_experiment_layer", "type": "dynamic_config", - "salt": "f8aeba58-18fb-4f36-9bbd-4c611447a912", + "salt": "ed4fd4c3-4bd2-4b3d-891a-4bde19e17170", "enabled": true, - "defaultValue": { - "experiment_param": "layer_default", - "layer_param": true, - "second_layer_param": false - }, + "defaultValue": {}, "rules": [ { "name": "experimentAssignment", @@ -407,17 +477,13 @@ "operator": "any", "field": null, "additionalValues": { - "salt": "58d96daa-4d16-467a-b616-7232c45153f4" + "salt": "ed4fd4c3-4bd2-4b3d-891a-4bde19e17170" }, "isDeviceBased": false, "idType": "userID" } ], - "returnValue": { - "experiment_param": "layer_default", - "layer_param": true, - "second_layer_param": false - }, + "returnValue": {}, "id": "experimentAssignment", "salt": "", "isDeviceBased": false, @@ -430,56 +496,50 @@ "entity": "layer" }, { - "name": "b_layer_no_alloc", - "type": "dynamic_config", - "salt": "3e361046-bc69-4dfd-bbb1-538afe609157", - "enabled": true, - "defaultValue": { - "b_param": "layer_default" - }, - "rules": [], - "isDeviceBased": false, - "idType": "userID", - "entity": "layer" - }, - { - "name": "c_layer_with_holdout", + "name": "statsig::test_layer", "type": "dynamic_config", - "salt": "ab40c4af-947f-411e-9403-880393703507", + "salt": "a250c016-2627-4214-9d53-3761d073ef0f", "enabled": true, - "defaultValue": { - "holdout_layer_param": "layer_default" - }, + "defaultValue": {}, "rules": [ { - "name": "7d2E854TtGmfETdmJFip1L", - "groupName": "master_holdout", + "name": "experimentAssignment", + "groupName": "Experiment Assignment", "passPercentage": 100, "conditions": [ { - "type": "pass_gate", - "targetValue": "always_on_gate", + "type": "user_bucket", + "targetValue": [ + 1, 3, 11, 13, 18, 26, 29, 35, 44, 50, 59, 62, 75, 102, 138, 146, + 149, 166, 172, 177, 182, 198, 215, 220, 240, 267, 285, 290, 296, + 299, 311, 317, 323, 326, 362, 363, 374, 386, 405, 411, 440, 442, + 447, 449, 452, 459, 464, 474, 482, 493, 496, 501, 508, 514, 523, + 529, 542, 566, 569, 588, 595, 600, 606, 608, 613, 631, 632, 637, + 662, 670, 676, 692, 696, 700, 721, 722, 723, 730, 737, 750, 779, + 781, 799, 808, 814, 816, 834, 843, 856, 862, 894, 908, 912, 914, + 929, 930, 933, 936, 988, 991 + ], "operator": "any", "field": null, - "additionalValues": null, + "additionalValues": { + "salt": "a250c016-2627-4214-9d53-3761d073ef0f" + }, "isDeviceBased": false, "idType": "userID" } ], - "returnValue": { - "holdout_layer_param": "layer_default" - }, - "id": "7d2E854TtGmfETdmJFip1L", + "returnValue": {}, + "id": "experimentAssignment", "salt": "", "isDeviceBased": false, - "idType": "userID" + "idType": "userID", + "configDelegate": "test" } ], "isDeviceBased": false, "idType": "userID", "entity": "layer" }, - { "name": "d_layer_delegate_to_fallback", "type": "dynamic_config", @@ -514,9 +574,5 @@ } ], "has_updates": true, - "time": 1631638014811, - "id_lists": { - "list_1": true, - "list_2": true - } + "time": 1648589257397 } diff --git a/src/__tests__/initialize_response.json b/src/__tests__/initialize_response.json index 6298bcb..fdfd471 100644 --- a/src/__tests__/initialize_response.json +++ b/src/__tests__/initialize_response.json @@ -9,13 +9,13 @@ "rGc+6rvo48V4j1sXkvsGHeSfJfY7kMp1OHfQnw+3XbI=": { "name": "rGc+6rvo48V4j1sXkvsGHeSfJfY7kMp1OHfQnw+3XbI=", "value": true, - "rule_id": "6N6Z8ODekNYZ7F8gFdoLP5", + "rule_id": "2DWuOvXQZWKvoaNm27dqcs", "secondary_exposures": [] }, "srhBjpz3NFHATrntxEsdHManNXJQZawtJUkQ3s0XT3Q=": { "name": "srhBjpz3NFHATrntxEsdHManNXJQZawtJUkQ3s0XT3Q=", "value": true, - "rule_id": "7w9rbTSffLT89pxqpyhuqK", + "rule_id": "3jdTW54SQWbbxFFZJe7wYZ", "secondary_exposures": [] } }, @@ -27,27 +27,22 @@ "string": "statsig", "boolean": false }, - "group": "1kNmlB23wylPFZi1M0Divl", - "rule_id": "1kNmlB23wylPFZi1M0Divl", + "rule_id": "4lInPNRUnjUzaWNkEWLFA9", + "group": "4lInPNRUnjUzaWNkEWLFA9", "is_device_based": false, - "is_experiment_active": false, - "is_user_in_experiment": false, "secondary_exposures": [] }, "86mj1zUC35KaWoOqpe8SKEHQjRlXOzdLfiTJU/iU+vc=": { "name": "86mj1zUC35KaWoOqpe8SKEHQjRlXOzdLfiTJU/iU+vc=", "value": { - "experiment_param": "test", - "layer_param": true, - "second_layer_param": true + "sample_parameter": false }, - "group": "2RamGujUou6h2bVNQWhtNZ", - "rule_id": "2RamGujUou6h2bVNQWhtNZ", + "rule_id": "5yQbPMfmKQdiRV35hS3B2l", + "group": "5yQbPMfmKQdiRV35hS3B2l", "is_device_based": false, - "is_experiment_active": false, - "is_user_in_experiment": false, "secondary_exposures": [], - "explicit_parameters": ["experiment_param"] + "is_experiment_active": true, + "is_user_in_experiment": true }, "mQ5nTisJBEdOZO0gMeZ7E+pa1409+e96sYkKE8XkVTA=": { "group": "", @@ -58,63 +53,51 @@ "rule_id": "", "secondary_exposures": [], "value": {} + }, + "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=": { + "name": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", + "value": {}, + "rule_id": "prestart", + "group": "prestart", + "secondary_exposures": [], + "is_device_based": false, + "is_experiment_active": false, + "is_user_in_experiment": false } }, "layer_configs": { - "f/PP7qbCqgLC28wM1ttndrM3x7KFsdceSrT7H4e7Wck=": { - "name": "f/PP7qbCqgLC28wM1ttndrM3x7KFsdceSrT7H4e7Wck=", + "yD2Hj5ZefkntKmIwhN9K8Hvdh7helm9hqzp8B9Ng8xc=": { + "name": "yD2Hj5ZefkntKmIwhN9K8Hvdh7helm9hqzp8B9Ng8xc=", "value": { - "experiment_param": "test", - "layer_param": true, - "second_layer_param": true + "sample_parameter": false }, - "group": "2RamGujUou6h2bVNQWhtNZ", - "rule_id": "2RamGujUou6h2bVNQWhtNZ", - "is_device_based": false, + "rule_id": "5yQbPMfmKQdiRV35hS3B2l", + "group": "5yQbPMfmKQdiRV35hS3B2l", "secondary_exposures": [], - "undelegated_secondary_exposures": [], + "is_device_based": false, + "explicit_parameters": [], "allocated_experiment_name": "86mj1zUC35KaWoOqpe8SKEHQjRlXOzdLfiTJU/iU+vc=", "is_experiment_active": true, "is_user_in_experiment": true, - "explicit_parameters": ["experiment_param"] + "undelegated_secondary_exposures": [] }, - "swfBsA2gj0FY/9mzJdHytSKjrIsN1B9nJMeTWdKN2yw=": { - "name": "swfBsA2gj0FY/9mzJdHytSKjrIsN1B9nJMeTWdKN2yw=", - "value": { - "b_param": "layer_default" - }, - "group": "default", - "rule_id": "default", + "FTO1PR918Bsjs1iw6DncMVhpCn/IOS7ZqNYbj2i9Iqg=": { + "name": "FTO1PR918Bsjs1iw6DncMVhpCn/IOS7ZqNYbj2i9Iqg=", + "value": {}, "is_device_based": false, + "explicit_parameters": [], + "rule_id": "default", + "group": "default", "secondary_exposures": [], - "undelegated_secondary_exposures": [], - "explicit_parameters": [] - }, - "gxLOmmNNwVd8rUKILhDYkZwuLBxBuwWV1xn8m0aTs/E=": { - "name": "gxLOmmNNwVd8rUKILhDYkZwuLBxBuwWV1xn8m0aTs/E=", - "value": { - "holdout_layer_param": "layer_default" - }, - "group": "7d2E854TtGmfETdmJFip1L", - "rule_id": "7d2E854TtGmfETdmJFip1L", - "is_device_based": false, - "secondary_exposures": [ - { - "gate": "always_on_gate", - "gateValue": "true", - "ruleID": "6N6Z8ODekNYZ7F8gFdoLP5" - } - ], - "undelegated_secondary_exposures": [], - "explicit_parameters": [] + "undelegated_secondary_exposures": [] }, "VHos7km3/fakqhmzEGAt7ATTscBP6wEqSUicn9BX1IM=": { "allocated_experiment_name": "mQ5nTisJBEdOZO0gMeZ7E+pa1409+e96sYkKE8XkVTA=", "explicit_parameters": [], "group": "", "is_device_based": false, - "is_experiment_active": true, - "is_user_in_experiment": true, + "is_experiment_active": false, + "is_user_in_experiment": false, "name": "VHos7km3/fakqhmzEGAt7ATTscBP6wEqSUicn9BX1IM=", "rule_id": "", "secondary_exposures": [], @@ -124,5 +107,6 @@ }, "sdkParams": {}, "has_updates": true, - "time": 0 + "time": 0, + "generator": "statsig-node-sdk" }