From cc847c0bfde1cf7962c79d36e9a8dbae915d540b Mon Sep 17 00:00:00 2001 From: kenny-statsig <111380336+kenny-statsig@users.noreply.github.com> Date: Fri, 16 Aug 2024 10:22:56 -0700 Subject: [PATCH] Persistent assignment (#485) See Go, Ruby, or any local eval SDK. https://app.graphite.dev/github/pr/statsig-io/kong/2484/Persistent-assignment-in-Node --- src/ConfigEvaluation.ts | 42 +- src/EvaluationDetails.ts | 8 + src/EvaluationReason.ts | 3 +- src/Evaluator.ts | 94 +- src/StatsigOptions.ts | 14 + src/StatsigServer.ts | 32 +- src/UserPersistentStorageHandler.ts | 86 ++ src/__tests__/PersistentAssignment.test.ts | 214 +++ ...nload_config_specs_sticky_experiments.json | 629 +++++++++ ...fig_specs_sticky_experiments_inactive.json | 1250 +++++++++++++++++ src/index.ts | 40 +- src/interfaces/IUserPersistentStorage.ts | 45 + src/utils/EvaluatorUtils.ts | 6 +- src/utils/StatsigContext.ts | 4 + 14 files changed, 2451 insertions(+), 16 deletions(-) create mode 100644 src/UserPersistentStorageHandler.ts create mode 100644 src/__tests__/PersistentAssignment.test.ts create mode 100644 src/__tests__/data/download_config_specs_sticky_experiments.json create mode 100644 src/__tests__/data/download_config_specs_sticky_experiments_inactive.json create mode 100644 src/interfaces/IUserPersistentStorage.ts diff --git a/src/ConfigEvaluation.ts b/src/ConfigEvaluation.ts index 59e093a..5aa9e9a 100644 --- a/src/ConfigEvaluation.ts +++ b/src/ConfigEvaluation.ts @@ -1,4 +1,5 @@ import { EvaluationDetails } from './EvaluationDetails'; +import type { StickyValues } from './interfaces/IUserPersistentStorage'; import { SecondaryExposure } from './LogEvent'; export default class ConfigEvaluation { @@ -9,7 +10,7 @@ export default class ConfigEvaluation { public explicit_parameters: string[] | null; public config_delegate: string | null; public unsupported: boolean; - public undelegated_secondary_exposures: SecondaryExposure[] | undefined; + public undelegated_secondary_exposures: SecondaryExposure[]; public is_experiment_group: boolean; public group_name: string | null; public evaluation_details: EvaluationDetails | undefined; @@ -70,4 +71,43 @@ export default class ConfigEvaluation { EvaluationDetails.unsupported(configSyncTime, initialUpdateTime), ); } + + public toStickyValues(): StickyValues { + return { + value: this.value, + json_value: this.json_value, + rule_id: this.rule_id, + group_name: this.group_name, + secondary_exposures: this.secondary_exposures, + undelegated_secondary_exposures: this.undelegated_secondary_exposures, + config_delegate: this.config_delegate, + explicit_parameters: this.explicit_parameters, + time: this.evaluation_details?.configSyncTime ?? Date.now(), + }; + } + + public static fromStickyValues( + stickyValues: StickyValues, + initialUpdateTime: number, + ): ConfigEvaluation { + const evaluation = new ConfigEvaluation( + stickyValues.value, + stickyValues.rule_id, + stickyValues.group_name, + null, + stickyValues.secondary_exposures, + stickyValues.json_value, + stickyValues.explicit_parameters, + stickyValues.config_delegate, + ); + evaluation.evaluation_details = EvaluationDetails.persisted( + stickyValues.time, + initialUpdateTime, + ); + evaluation.undelegated_secondary_exposures = + stickyValues.undelegated_secondary_exposures; + evaluation.is_experiment_group = true; + + return evaluation; + } } diff --git a/src/EvaluationDetails.ts b/src/EvaluationDetails.ts index 83badab..c576233 100644 --- a/src/EvaluationDetails.ts +++ b/src/EvaluationDetails.ts @@ -29,6 +29,14 @@ export class EvaluationDetails { ); } + static persisted(configSyncTime: number, initialUpdateTime: number) { + return new EvaluationDetails( + configSyncTime, + initialUpdateTime, + 'Persisted', + ); + } + static make( configSyncTime: number, initialUpdateTime: number, diff --git a/src/EvaluationReason.ts b/src/EvaluationReason.ts index 33119bb..810ec9d 100644 --- a/src/EvaluationReason.ts +++ b/src/EvaluationReason.ts @@ -5,4 +5,5 @@ export type EvaluationReason = | 'Uninitialized' | 'Bootstrap' | 'DataAdapter' - | 'Unsupported'; + | 'Unsupported' + | 'Persisted'; diff --git a/src/Evaluator.ts b/src/Evaluator.ts index a319beb..c065cd7 100644 --- a/src/Evaluator.ts +++ b/src/Evaluator.ts @@ -3,12 +3,14 @@ import ip3country from 'ip3country'; import ConfigEvaluation from './ConfigEvaluation'; import { ConfigCondition, ConfigRule, ConfigSpec } from './ConfigSpec'; import { EvaluationDetails } from './EvaluationDetails'; +import { UserPersistedValues } from './interfaces/IUserPersistentStorage'; import { SecondaryExposure } from './LogEvent'; import OutputLogger from './OutputLogger'; import SpecStore, { APIEntityNames } from './SpecStore'; import { ExplicitStatsigOptions, InitStrategy } from './StatsigOptions'; import { ClientInitializeResponseOptions } from './StatsigServer'; import { StatsigUser } from './StatsigUser'; +import UserPersistentStorageHandler from './UserPersistentStorageHandler'; import { cloneEnforce, getSDKType, getSDKVersion } from './utils/core'; import { arrayAny, @@ -74,9 +76,8 @@ export default class Evaluator { Record> >; private initialized = false; - private store: SpecStore; - + private persistentStore: UserPersistentStorageHandler; private initStrategyForIP3Country: InitStrategy; public constructor(fetcher: StatsigFetcher, options: ExplicitStatsigOptions) { @@ -85,6 +86,9 @@ export default class Evaluator { this.gateOverrides = {}; this.configOverrides = {}; this.layerOverrides = {}; + this.persistentStore = new UserPersistentStorageHandler( + options.userPersistentStorage, + ); } public async init(): Promise { @@ -196,8 +200,11 @@ export default class Evaluator { ); return this.getUnrecognizedEvaluation(); } - return this._evalSpec( - EvaluationContext.get(ctx.getRequestContext(), { user, spec: config }), + return this._evalConfig( + EvaluationContext.get(ctx.getRequestContext(), { + user, + spec: config, + }), ); } @@ -230,11 +237,18 @@ export default class Evaluator { ); return this.getUnrecognizedEvaluation(); } - return this._evalSpec( + return this._evalLayer( EvaluationContext.get(ctx.getRequestContext(), { user, spec: layer }), ); } + public getUserPersistedValues( + user: StatsigUser, + idType: string, + ): UserPersistedValues { + return this.persistentStore.load(user, idType) ?? {}; + } + public getClientInitializeResponse( inputUser: StatsigUser, ctx: StatsigContext, @@ -631,6 +645,76 @@ export default class Evaluator { ); } + _evalConfig(ctx: EvaluationContext): ConfigEvaluation { + const { user, spec, userPersistedValues } = ctx; + if (userPersistedValues == null || !spec.isActive) { + this.persistentStore.delete(user, spec.idType, spec.name); + return this._evalSpec(ctx); + } + + const stickyConfig = userPersistedValues[spec.name]; + const stickyEvaluation = stickyConfig + ? ConfigEvaluation.fromStickyValues( + stickyConfig, + this.store.getInitialUpdateTime(), + ) + : null; + + if (stickyEvaluation) { + return stickyEvaluation; + } + + const evaluation = this._evalSpec(ctx); + + if (evaluation.is_experiment_group) { + this.persistentStore.save(user, spec.idType, spec.name, evaluation); + } + + return evaluation; + } + + _evalLayer(ctx: EvaluationContext): ConfigEvaluation { + const { user, spec, userPersistedValues } = ctx; + if (!userPersistedValues) { + this.persistentStore.delete(user, spec.idType, spec.name); + return this._evalSpec(ctx); + } + + const stickyConfig = userPersistedValues[spec.name]; + const stickyEvaluation = stickyConfig + ? ConfigEvaluation.fromStickyValues( + stickyConfig, + this.store.getInitialUpdateTime(), + ) + : null; + + const isAllocatedExperimentActive = (evaluation: ConfigEvaluation) => + this._isExperimentActive( + evaluation.config_delegate + ? this.store.getConfig(evaluation.config_delegate) + : null, + ); + + if (stickyEvaluation) { + if (isAllocatedExperimentActive(stickyEvaluation)) { + return stickyEvaluation; + } else { + this.persistentStore.delete(user, spec.idType, spec.name); + return this._evalSpec(ctx); + } + } else { + const evaluation = this._evalSpec(ctx); + if (isAllocatedExperimentActive(evaluation)) { + if (evaluation.is_experiment_group) { + this.persistentStore.save(user, spec.idType, spec.name, evaluation); + } + } else { + this.persistentStore.delete(user, spec.idType, spec.name); + } + return evaluation; + } + } + _evalSpec(ctx: EvaluationContext): ConfigEvaluation { const evaulation = this._eval(ctx); if (evaulation.evaluation_details) { diff --git a/src/StatsigOptions.ts b/src/StatsigOptions.ts index 6b510f4..0040592 100644 --- a/src/StatsigOptions.ts +++ b/src/StatsigOptions.ts @@ -1,4 +1,8 @@ import { IDataAdapter } from './interfaces/IDataAdapter'; +import { + IUserPersistentStorage, + UserPersistedValues, +} from './interfaces/IUserPersistentStorage'; import { STATSIG_API, STATSIG_CDN } from './utils/StatsigFetcher'; const DEFAULT_RULESETS_SYNC_INTERVAL = 10 * 1000; @@ -53,6 +57,7 @@ export type ExplicitStatsigOptions = { disableRulesetsSync: boolean; disableIdListsSync: boolean; disableAllLogging: boolean; + userPersistentStorage: IUserPersistentStorage | null; }; /** @@ -130,6 +135,7 @@ export function OptionsWithDefaults( disableRulesetsSync: opts.disableRulesetsSync ?? false, disableIdListsSync: opts.disableIdListsSync ?? false, disableAllLogging: opts.disableAllLogging ?? false, + userPersistentStorage: opts.userPersistentStorage ?? null, }; } @@ -230,3 +236,11 @@ function getNumber( function normalizeUrl(url: string | null): string | null { return url && url.endsWith('/') ? url.slice(0, -1) : url; } + +export type GetExperimentOptions = { + userPersistedValues?: UserPersistedValues | null; +}; + +export type GetLayerOptions = { + userPersistedValues?: UserPersistedValues | null; +}; diff --git a/src/StatsigServer.ts b/src/StatsigServer.ts index aad531e..dc29f56 100644 --- a/src/StatsigServer.ts +++ b/src/StatsigServer.ts @@ -14,12 +14,15 @@ import { makeEmptyFeatureGate, makeFeatureGate, } from './FeatureGate'; +import { UserPersistedValues } from './interfaces/IUserPersistentStorage'; import Layer from './Layer'; import LogEvent from './LogEvent'; import LogEventProcessor from './LogEventProcessor'; import OutputLogger from './OutputLogger'; import { ExplicitStatsigOptions, + GetExperimentOptions, + GetLayerOptions, OptionsLoggingCopy, OptionsWithDefaults, StatsigOptions, @@ -298,6 +301,7 @@ export default class StatsigServer { public getExperimentSync( user: StatsigUser, experimentName: string, + options?: GetExperimentOptions, ): DynamicConfig { return this._errorBoundary.capture( (ctx) => @@ -306,6 +310,7 @@ export default class StatsigServer { StatsigContext.new({ caller: 'getExperiment', configName: experimentName, + userPersistedValues: options?.userPersistedValues, }), ); } @@ -313,6 +318,7 @@ export default class StatsigServer { public getExperimentWithExposureLoggingDisabledSync( user: StatsigUser, experimentName: string, + options?: GetExperimentOptions, ): DynamicConfig { return this._errorBoundary.capture( (ctx) => @@ -321,6 +327,7 @@ export default class StatsigServer { StatsigContext.new({ caller: 'getExperimentWithExposureLoggingDisabled', configName: experimentName, + userPersistedValues: options?.userPersistedValues, }), ); } @@ -369,17 +376,26 @@ export default class StatsigServer { * @throws Error if initialize() was not called first * @throws Error if the layerName is not provided or not a non-empty string */ - public getLayerSync(user: StatsigUser, layerName: string): Layer { + public getLayerSync( + user: StatsigUser, + layerName: string, + options?: GetLayerOptions, + ): Layer { return this._errorBoundary.capture( (ctx) => this.getLayerImpl(user, layerName, ExposureLogging.Enabled, ctx), () => new Layer(layerName), - StatsigContext.new({ caller: 'getLayer', configName: layerName }), + StatsigContext.new({ + caller: 'getLayer', + configName: layerName, + userPersistedValues: options?.userPersistedValues, + }), ); } public getLayerWithExposureLoggingDisabledSync( user: StatsigUser, layerName: string, + options?: GetLayerOptions, ): Layer { return this._errorBoundary.capture( (ctx) => @@ -388,6 +404,7 @@ export default class StatsigServer { StatsigContext.new({ caller: 'getLayerWithExposureLoggingDisabled', configName: layerName, + userPersistedValues: options?.userPersistedValues, }), ); } @@ -417,6 +434,17 @@ export default class StatsigServer { //#endregion + public getUserPersistedValues( + user: StatsigUser, + idType: string, + ): UserPersistedValues { + return this._errorBoundary.capture( + () => this._evaluator.getUserPersistedValues(user, idType), + () => ({}), + StatsigContext.new({ caller: 'getUserPersistedValues' }), + ); + } + /** * Log an event for data analysis and alerting or to measure the impact of an experiment * @throws Error if initialize() was not called first diff --git a/src/UserPersistentStorageHandler.ts b/src/UserPersistentStorageHandler.ts new file mode 100644 index 0000000..643efcb --- /dev/null +++ b/src/UserPersistentStorageHandler.ts @@ -0,0 +1,86 @@ +import type ConfigEvaluation from './ConfigEvaluation'; +import { + IUserPersistentStorage, + UserPersistedValues, +} from './interfaces/IUserPersistentStorage'; +import OutputLogger from './OutputLogger'; +import type { StatsigUser } from './StatsigUser'; +import { getUnitID } from './utils/EvaluatorUtils'; + +export default class UserPersistentStorageHandler { + constructor(private storage: IUserPersistentStorage | null) {} + + public load(user: StatsigUser, idType: string): UserPersistedValues | null { + if (this.storage == null) { + return null; + } + + const key = UserPersistentStorageHandler.getStorageKey(user, idType); + if (!key) { + return null; + } + + try { + return this.storage.load(key); + } catch (e) { + OutputLogger.error( + `statsigSDK> Failed to load persisted values for key ${key} (${(e as Error).message})`, + ); + return null; + } + } + + public save( + user: StatsigUser, + idType: string, + configName: string, + evaluation: ConfigEvaluation, + ): void { + if (this.storage == null) { + return; + } + + const key = UserPersistentStorageHandler.getStorageKey(user, idType); + if (!key) { + return; + } + + try { + this.storage.save(key, configName, evaluation.toStickyValues()); + } catch (e) { + OutputLogger.error( + `statsigSDK> Failed to save persisted values for key ${key} (${(e as Error).message})`, + ); + } + } + + public delete(user: StatsigUser, idType: string, configName: string): void { + if (this.storage == null) { + return; + } + + const key = UserPersistentStorageHandler.getStorageKey(user, idType); + if (!key) { + return; + } + + try { + this.storage.delete(key, configName); + } catch (e) { + OutputLogger.error( + `statsigSDK> Failed to delete persisted values for key ${key} (${(e as Error).message})`, + ); + } + } + + private static getStorageKey( + user: StatsigUser, + idType: string, + ): string | null { + const unitID = getUnitID(user, idType); + if (!unitID) { + OutputLogger.warn(`statsigSDK> No unit ID found for ID type ${idType}`); + } + return `${unitID ?? ''}:${idType}`; + } +} diff --git a/src/__tests__/PersistentAssignment.test.ts b/src/__tests__/PersistentAssignment.test.ts new file mode 100644 index 0000000..9a22899 --- /dev/null +++ b/src/__tests__/PersistentAssignment.test.ts @@ -0,0 +1,214 @@ +import { StatsigServer, StatsigUser } from '../index'; +import { + IUserPersistentStorage, + StickyValues, + UserPersistedValues, +} from '../interfaces/IUserPersistentStorage'; + +class TestPersistentStorage implements IUserPersistentStorage { + public store: Record = {}; + load(key: string): UserPersistedValues { + return this.store[key]; + } + save(key: string, configName: string, data: StickyValues): void { + if (!(key in this.store)) { + this.store[key] = {}; + } + this.store[key][configName] = data; + } + delete(key: string, configName: string): void { + delete this.store[key][configName]; + } +} + +describe('Persistent Assignment', () => { + const userInControl: StatsigUser = { userID: 'vj' }; + const userInTest: StatsigUser = { userID: 'hunter2' }; + const userNotInExp: StatsigUser = { userID: 'gb' }; + const experimentName = 'the_allocated_experiment'; + const persistentStorage = new TestPersistentStorage(); + const spy = { + load: jest.spyOn(persistentStorage, 'load'), + save: jest.spyOn(persistentStorage, 'save'), + delete: jest.spyOn(persistentStorage, 'delete'), + }; + let statsig: StatsigServer; + + beforeAll(async () => { + const configSpecs = JSON.stringify( + require('./data/download_config_specs_sticky_experiments.json'), + ); + statsig = new StatsigServer('secret-key', { + bootstrapValues: configSpecs, + userPersistentStorage: persistentStorage, + }); + await statsig.initializeAsync(); + }); + + test('Not using persistent storage', () => { + let exp = statsig.getExperimentSync(userInControl, experimentName); + expect(exp.getGroupName()).toEqual('Control'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + exp = statsig.getExperimentSync(userInTest, experimentName); + expect(exp.getGroupName()).toEqual('Test'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + exp = statsig.getExperimentSync(userNotInExp, experimentName); + expect(exp.getGroupName()).toEqual('Layer Assignment'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + expect(Object.keys(persistentStorage.store).length).toEqual(0); + expect(spy.save).toHaveBeenCalledTimes(0); + }); + + test('Assignments saved to persistent storage', () => { + let exp = statsig.getExperimentSync(userInControl, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userInControl, + 'userID', + ), + }); + expect(exp.getGroupName()).toEqual('Control'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + exp = statsig.getExperimentSync(userInTest, experimentName, { + userPersistedValues: statsig.getUserPersistedValues(userInTest, 'userID'), + }); + expect(exp.getGroupName()).toEqual('Test'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + expect(Object.keys(persistentStorage.store).length).toEqual(2); + expect(spy.save).toHaveBeenCalledTimes(2); + }); + + test('Evaluating from persistent assignments', () => { + // Use sticky bucketing with valid persisted values + // (Should override userInControl to the first evaluation of userInControl) + let exp = statsig.getExperimentSync(userInControl, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userInControl, + 'userID', + ), + }); + expect(exp.getGroupName()).toEqual('Control'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Persisted'); + + // Use sticky bucketing with valid persisted values + // (Should override userInTest to the first evaluation of userInTest) + exp = statsig.getExperimentSync(userInTest, experimentName, { + userPersistedValues: statsig.getUserPersistedValues(userInTest, 'userID'), + }); + expect(exp.getGroupName()).toEqual('Test'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Persisted'); + + // Use sticky bucketing with valid persisted values to assign a user that would otherwise be unallocated + // (Should override userNotInExp to the first evaluation of userInControl) + exp = statsig.getExperimentSync(userNotInExp, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userInControl, + 'userID', + ), + }); + expect(exp.getGroupName()).toEqual('Control'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Persisted'); + + // Use sticky bucketing with valid persisted values for an unallocated user + // (Should not override since there are no persisted values) + exp = statsig.getExperimentSync(userNotInExp, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userNotInExp, + 'userID', + ), + }); + expect(exp.getGroupName()).toEqual('Layer Assignment'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + // Use sticky bucketing on a different ID type that hasn't been saved to storage + // (Should not override since there are no persisted values) + exp = statsig.getExperimentSync(userInTest, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userInTest, + 'stableID', + ), + }); + expect(exp.getGroupName()).toEqual('Test'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + + expect(Object.keys(persistentStorage.store).length).toEqual(2); + expect(spy.save).toHaveBeenCalledTimes(3); + }); + + test('Assignments deleted from persistent storage', async () => { + const configSpecs = JSON.stringify( + require('./data/download_config_specs_sticky_experiments_inactive.json'), + ); + statsig = new StatsigServer('secret-key', { + bootstrapValues: configSpecs, + userPersistentStorage: persistentStorage, + }); + await statsig.initializeAsync(); + + // Persisted assignment for inactive experiment is not used and deleted + let exp = statsig.getExperimentSync(userInControl, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userInControl, + 'userID', + ), + }); + expect(exp.getGroupName()).toEqual('Control'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + expect( + persistentStorage.store[`${userInControl.userID}:userID`]?.[ + experimentName + ], + ).toBeUndefined(); + + // Persisted assignment for experiment is removed if not provided during evaluation (opt-out) + exp = statsig.getExperimentSync(userInTest, experimentName); + expect(exp.getGroupName()).toEqual('Test'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + expect( + persistentStorage.store[`${userInTest.userID}:userID`]?.[experimentName], + ).toBeUndefined(); + }); + + test('Broken persistent storage', async () => { + const brokenPersistentStorage = new BrokenPersistentStorage(); + const configSpecs = JSON.stringify( + require('./data/download_config_specs_sticky_experiments.json'), + ); + statsig = new StatsigServer('secret-key', { + bootstrapValues: configSpecs, + userPersistentStorage: brokenPersistentStorage, + }); + await statsig.initializeAsync(); + + // Does not throw + try { + const exp = statsig.getExperimentSync(userInControl, experimentName, { + userPersistedValues: statsig.getUserPersistedValues( + userInControl, + 'userID', + ), + }); + expect(exp.getGroupName()).toEqual('Control'); + expect(exp.getEvaluationDetails()?.reason).toEqual('Bootstrap'); + } catch { + fail('Expected not to throw'); + } + }); +}); + +class BrokenPersistentStorage implements IUserPersistentStorage { + public store: Record = {}; + load(key: string): UserPersistedValues { + throw new Error('Invalid load'); + } + save(key: string, configName: string, data: StickyValues): void { + throw new Error('Invalid save'); + } + delete(key: string, configName: string): void { + throw new Error('Invalid delete'); + } +} diff --git a/src/__tests__/data/download_config_specs_sticky_experiments.json b/src/__tests__/data/download_config_specs_sticky_experiments.json new file mode 100644 index 0000000..7d467b7 --- /dev/null +++ b/src/__tests__/data/download_config_specs_sticky_experiments.json @@ -0,0 +1,629 @@ +{ + "dynamic_configs": [ + { + "name": "the_allocated_experiment", + "type": "dynamic_config", + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb", + "enabled": true, + "defaultValue": { + "an_int": 99, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "rules": [ + { + "name": "layerAssignment", + "groupName": "Layer Assignment", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": [ + 0, + 1, + 3, + 4, + 7, + 10, + 11, + 13, + 14, + 15, + 21, + 22, + 24, + 25, + 27, + 28, + 29, + 30, + 32, + 33, + 35, + 37, + 39, + 40, + 41, + 43, + 44, + 49, + 50, + 51, + 55, + 58, + 59, + 61, + 62, + 63, + 66, + 67, + 70, + 71, + 74, + 76, + 79, + 82, + 83, + 86, + 87, + 92, + 97, + 99, + 100, + 103, + 104, + 105, + 106, + 111, + 112, + 114, + 115, + 116, + 117, + 118, + 120, + 123, + 124, + 125, + 126, + 129, + 130, + 133, + 134, + 139, + 140, + 141, + 144, + 145, + 146, + 151, + 152, + 157, + 159, + 160, + 162, + 166, + 168, + 169, + 170, + 174, + 176, + 177, + 178, + 179, + 180, + 182, + 184, + 185, + 186, + 187, + 188, + 191, + 198, + 201, + 202, + 203, + 205, + 206, + 207, + 208, + 209, + 210, + 211, + 213, + 214, + 215, + 216, + 217, + 219, + 220, + 222, + 225, + 226, + 227, + 228, + 230, + 232, + 233, + 234, + 236, + 239, + 241, + 242, + 243, + 244, + 247, + 249, + 250, + 252, + 254, + 257, + 258, + 259, + 266, + 267, + 268, + 270, + 271, + 272, + 274, + 277, + 278, + 280, + 282, + 283, + 285, + 289, + 290, + 294, + 295, + 296, + 298, + 299, + 300, + 303, + 304, + 305, + 306, + 309, + 313, + 314, + 318, + 320, + 321, + 322, + 324, + 327, + 328, + 329, + 330, + 333, + 334, + 335, + 336, + 338, + 339, + 341, + 345, + 346, + 351, + 352, + 354, + 355, + 358, + 359, + 360, + 361, + 362, + 363, + 364, + 366, + 367, + 370, + 371, + 372, + 374, + 376, + 377, + 379, + 382, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 393, + 394, + 395, + 396, + 397, + 401, + 402, + 405, + 407, + 409, + 411, + 412, + 413, + 419, + 421, + 422, + 424, + 425, + 426, + 427, + 428, + 429, + 431, + 432, + 433, + 434, + 435, + 436, + 439, + 441, + 442, + 443, + 444, + 445, + 449, + 454, + 456, + 457, + 460, + 461, + 462, + 463, + 471, + 474, + 475, + 476, + 477, + 478, + 479, + 481, + 489, + 490, + 491, + 492, + 493, + 495, + 496, + 497, + 498, + 502, + 503, + 504, + 516, + 517, + 519, + 521, + 523, + 524, + 526, + 527, + 529, + 530, + 536, + 537, + 538, + 540, + 547, + 548, + 549, + 551, + 553, + 554, + 555, + 556, + 559, + 560, + 561, + 562, + 563, + 564, + 565, + 568, + 571, + 572, + 573, + 576, + 578, + 579, + 580, + 581, + 583, + 588, + 589, + 590, + 591, + 597, + 598, + 600, + 601, + 602, + 604, + 616, + 617, + 621, + 623, + 624, + 625, + 628, + 630, + 634, + 635, + 636, + 638, + 640, + 641, + 643, + 644, + 645, + 646, + 651, + 656, + 662, + 663, + 665, + 667, + 668, + 670, + 676, + 678, + 681, + 682, + 683, + 684, + 686, + 687, + 688, + 689, + 690, + 691, + 692, + 693, + 695, + 696, + 698, + 701, + 702, + 703, + 704, + 706, + 714, + 718, + 720, + 721, + 725, + 726, + 728, + 730, + 733, + 736, + 737, + 739, + 740, + 741, + 745, + 747, + 748, + 749, + 752, + 753, + 754, + 756, + 757, + 758, + 762, + 764, + 769, + 771, + 773, + 774, + 775, + 776, + 777, + 779, + 780, + 781, + 782, + 783, + 784, + 786, + 787, + 788, + 790, + 792, + 796, + 799, + 802, + 805, + 810, + 811, + 812, + 813, + 814, + 817, + 822, + 823, + 826, + 836, + 838, + 839, + 840, + 843, + 845, + 846, + 848, + 850, + 855, + 859, + 860, + 862, + 864, + 870, + 871, + 872, + 873, + 874, + 875, + 877, + 878, + 879, + 881, + 886, + 887, + 892, + 894, + 895, + 897, + 898, + 900, + 901, + 910, + 913, + 918, + 919, + 921, + 922, + 924, + 930, + 934, + 935, + 936, + 937, + 940, + 942, + 947, + 948, + 949, + 952, + 957, + 958, + 961, + 964, + 967, + 968, + 969, + 970, + 973, + 974, + 978, + 979, + 980, + 983, + 987, + 988, + 995, + 998 + ], + "operator": "none", + "field": null, + "additionalValues": { + "salt": "118e1b3e-3454-4311-b40a-4fcdf3539c35" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 99, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "layerAssignment", + "salt": "", + "isDeviceBased": false, + "idType": "userID" + }, + { + "name": "2B3nzOtBrlt32sH5nGffRl", + "groupName": "Control", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": 500, + "operator": "lt", + "field": null, + "additionalValues": { + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 12, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "2B3nzOtBrlt32sH5nGffRl", + "salt": "2B3nzOtBrlt32sH5nGffRl", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true + }, + { + "name": "2B3nzQ8DTDCxlSf0YOaTan", + "groupName": "Test", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": 1000, + "operator": "lt", + "field": null, + "additionalValues": { + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 8, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "2B3nzQ8DTDCxlSf0YOaTan", + "salt": "2B3nzQ8DTDCxlSf0YOaTan", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true + } + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "experiment", + "isActive": true, + "hasSharedParams": true, + "explicitParameters": [ + "an_int" + ] + } + ], + "feature_gates": [], + "layer_configs": [], + "has_updates": true, + "time": 1631638014811 +} \ No newline at end of file diff --git a/src/__tests__/data/download_config_specs_sticky_experiments_inactive.json b/src/__tests__/data/download_config_specs_sticky_experiments_inactive.json new file mode 100644 index 0000000..6ea6896 --- /dev/null +++ b/src/__tests__/data/download_config_specs_sticky_experiments_inactive.json @@ -0,0 +1,1250 @@ +{ + "dynamic_configs": [ + { + "name": "another_allocated_experiment_still_active", + "type": "dynamic_config", + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb", + "enabled": true, + "defaultValue": { + "an_int": 99, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "rules": [ + { + "name": "layerAssignment", + "groupName": "Layer Assignment", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": [ + 0, + 1, + 3, + 4, + 7, + 10, + 11, + 13, + 14, + 15, + 21, + 22, + 24, + 25, + 27, + 28, + 29, + 30, + 32, + 33, + 35, + 37, + 39, + 40, + 41, + 43, + 44, + 49, + 50, + 51, + 55, + 58, + 59, + 61, + 62, + 63, + 66, + 67, + 70, + 71, + 74, + 76, + 79, + 82, + 83, + 86, + 87, + 92, + 97, + 99, + 100, + 103, + 104, + 105, + 106, + 111, + 112, + 114, + 115, + 116, + 117, + 118, + 120, + 123, + 124, + 125, + 126, + 129, + 130, + 133, + 134, + 139, + 140, + 141, + 144, + 145, + 146, + 151, + 152, + 157, + 159, + 160, + 162, + 166, + 168, + 169, + 170, + 174, + 176, + 177, + 178, + 179, + 180, + 182, + 184, + 185, + 186, + 187, + 188, + 191, + 198, + 201, + 202, + 203, + 205, + 206, + 207, + 208, + 209, + 210, + 211, + 213, + 214, + 215, + 216, + 217, + 219, + 220, + 222, + 225, + 226, + 227, + 228, + 230, + 232, + 233, + 234, + 236, + 239, + 241, + 242, + 243, + 244, + 247, + 249, + 250, + 252, + 254, + 257, + 258, + 259, + 266, + 267, + 268, + 270, + 271, + 272, + 274, + 277, + 278, + 280, + 282, + 283, + 285, + 289, + 290, + 294, + 295, + 296, + 298, + 299, + 300, + 303, + 304, + 305, + 306, + 309, + 313, + 314, + 318, + 320, + 321, + 322, + 324, + 327, + 328, + 329, + 330, + 333, + 334, + 335, + 336, + 338, + 339, + 341, + 345, + 346, + 351, + 352, + 354, + 355, + 358, + 359, + 360, + 361, + 362, + 363, + 364, + 366, + 367, + 370, + 371, + 372, + 374, + 376, + 377, + 379, + 382, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 393, + 394, + 395, + 396, + 397, + 401, + 402, + 405, + 407, + 409, + 411, + 412, + 413, + 419, + 421, + 422, + 424, + 425, + 426, + 427, + 428, + 429, + 431, + 432, + 433, + 434, + 435, + 436, + 439, + 441, + 442, + 443, + 444, + 445, + 449, + 454, + 456, + 457, + 460, + 461, + 462, + 463, + 471, + 474, + 475, + 476, + 477, + 478, + 479, + 481, + 489, + 490, + 491, + 492, + 493, + 495, + 496, + 497, + 498, + 502, + 503, + 504, + 516, + 517, + 519, + 521, + 523, + 524, + 526, + 527, + 529, + 530, + 536, + 537, + 538, + 540, + 547, + 548, + 549, + 551, + 553, + 554, + 555, + 556, + 559, + 560, + 561, + 562, + 563, + 564, + 565, + 568, + 571, + 572, + 573, + 576, + 578, + 579, + 580, + 581, + 583, + 588, + 589, + 590, + 591, + 597, + 598, + 600, + 601, + 602, + 604, + 616, + 617, + 621, + 623, + 624, + 625, + 628, + 630, + 634, + 635, + 636, + 638, + 640, + 641, + 643, + 644, + 645, + 646, + 651, + 656, + 662, + 663, + 665, + 667, + 668, + 670, + 676, + 678, + 681, + 682, + 683, + 684, + 686, + 687, + 688, + 689, + 690, + 691, + 692, + 693, + 695, + 696, + 698, + 701, + 702, + 703, + 704, + 706, + 714, + 718, + 720, + 721, + 725, + 726, + 728, + 730, + 733, + 736, + 737, + 739, + 740, + 741, + 745, + 747, + 748, + 749, + 752, + 753, + 754, + 756, + 757, + 758, + 762, + 764, + 769, + 771, + 773, + 774, + 775, + 776, + 777, + 779, + 780, + 781, + 782, + 783, + 784, + 786, + 787, + 788, + 790, + 792, + 796, + 799, + 802, + 805, + 810, + 811, + 812, + 813, + 814, + 817, + 822, + 823, + 826, + 836, + 838, + 839, + 840, + 843, + 845, + 846, + 848, + 850, + 855, + 859, + 860, + 862, + 864, + 870, + 871, + 872, + 873, + 874, + 875, + 877, + 878, + 879, + 881, + 886, + 887, + 892, + 894, + 895, + 897, + 898, + 900, + 901, + 910, + 913, + 918, + 919, + 921, + 922, + 924, + 930, + 934, + 935, + 936, + 937, + 940, + 942, + 947, + 948, + 949, + 952, + 957, + 958, + 961, + 964, + 967, + 968, + 969, + 970, + 973, + 974, + 978, + 979, + 980, + 983, + 987, + 988, + 995, + 998 + ], + "operator": "none", + "field": null, + "additionalValues": { + "salt": "118e1b3e-3454-4311-b40a-4fcdf3539c35" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 99, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "layerAssignment", + "salt": "", + "isDeviceBased": false, + "idType": "userID" + }, + { + "name": "2B3nzOtBrlt32sH5nGffRl", + "groupName": "Control", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": 500, + "operator": "lt", + "field": null, + "additionalValues": { + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 12, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "2B3nzOtBrlt32sH5nGffRl", + "salt": "2B3nzOtBrlt32sH5nGffRl", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true + }, + { + "name": "2B3nzQ8DTDCxlSf0YOaTan", + "groupName": "Test", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": 1000, + "operator": "lt", + "field": null, + "additionalValues": { + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 8, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "2B3nzQ8DTDCxlSf0YOaTan", + "salt": "2B3nzQ8DTDCxlSf0YOaTan", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true + } + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "experiment", + "isActive": true, + "hasSharedParams": true, + "explicitParameters": [ + "an_int" + ] + }, + { + "name": "the_allocated_experiment", + "type": "dynamic_config", + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb", + "enabled": true, + "defaultValue": { + "an_int": 99, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "rules": [ + { + "name": "layerAssignment", + "groupName": "Layer Assignment", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": [ + 0, + 1, + 3, + 4, + 7, + 10, + 11, + 13, + 14, + 15, + 21, + 22, + 24, + 25, + 27, + 28, + 29, + 30, + 32, + 33, + 35, + 37, + 39, + 40, + 41, + 43, + 44, + 49, + 50, + 51, + 55, + 58, + 59, + 61, + 62, + 63, + 66, + 67, + 70, + 71, + 74, + 76, + 79, + 82, + 83, + 86, + 87, + 92, + 97, + 99, + 100, + 103, + 104, + 105, + 106, + 111, + 112, + 114, + 115, + 116, + 117, + 118, + 120, + 123, + 124, + 125, + 126, + 129, + 130, + 133, + 134, + 139, + 140, + 141, + 144, + 145, + 146, + 151, + 152, + 157, + 159, + 160, + 162, + 166, + 168, + 169, + 170, + 174, + 176, + 177, + 178, + 179, + 180, + 182, + 184, + 185, + 186, + 187, + 188, + 191, + 198, + 201, + 202, + 203, + 205, + 206, + 207, + 208, + 209, + 210, + 211, + 213, + 214, + 215, + 216, + 217, + 219, + 220, + 222, + 225, + 226, + 227, + 228, + 230, + 232, + 233, + 234, + 236, + 239, + 241, + 242, + 243, + 244, + 247, + 249, + 250, + 252, + 254, + 257, + 258, + 259, + 266, + 267, + 268, + 270, + 271, + 272, + 274, + 277, + 278, + 280, + 282, + 283, + 285, + 289, + 290, + 294, + 295, + 296, + 298, + 299, + 300, + 303, + 304, + 305, + 306, + 309, + 313, + 314, + 318, + 320, + 321, + 322, + 324, + 327, + 328, + 329, + 330, + 333, + 334, + 335, + 336, + 338, + 339, + 341, + 345, + 346, + 351, + 352, + 354, + 355, + 358, + 359, + 360, + 361, + 362, + 363, + 364, + 366, + 367, + 370, + 371, + 372, + 374, + 376, + 377, + 379, + 382, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 393, + 394, + 395, + 396, + 397, + 401, + 402, + 405, + 407, + 409, + 411, + 412, + 413, + 419, + 421, + 422, + 424, + 425, + 426, + 427, + 428, + 429, + 431, + 432, + 433, + 434, + 435, + 436, + 439, + 441, + 442, + 443, + 444, + 445, + 449, + 454, + 456, + 457, + 460, + 461, + 462, + 463, + 471, + 474, + 475, + 476, + 477, + 478, + 479, + 481, + 489, + 490, + 491, + 492, + 493, + 495, + 496, + 497, + 498, + 502, + 503, + 504, + 516, + 517, + 519, + 521, + 523, + 524, + 526, + 527, + 529, + 530, + 536, + 537, + 538, + 540, + 547, + 548, + 549, + 551, + 553, + 554, + 555, + 556, + 559, + 560, + 561, + 562, + 563, + 564, + 565, + 568, + 571, + 572, + 573, + 576, + 578, + 579, + 580, + 581, + 583, + 588, + 589, + 590, + 591, + 597, + 598, + 600, + 601, + 602, + 604, + 616, + 617, + 621, + 623, + 624, + 625, + 628, + 630, + 634, + 635, + 636, + 638, + 640, + 641, + 643, + 644, + 645, + 646, + 651, + 656, + 662, + 663, + 665, + 667, + 668, + 670, + 676, + 678, + 681, + 682, + 683, + 684, + 686, + 687, + 688, + 689, + 690, + 691, + 692, + 693, + 695, + 696, + 698, + 701, + 702, + 703, + 704, + 706, + 714, + 718, + 720, + 721, + 725, + 726, + 728, + 730, + 733, + 736, + 737, + 739, + 740, + 741, + 745, + 747, + 748, + 749, + 752, + 753, + 754, + 756, + 757, + 758, + 762, + 764, + 769, + 771, + 773, + 774, + 775, + 776, + 777, + 779, + 780, + 781, + 782, + 783, + 784, + 786, + 787, + 788, + 790, + 792, + 796, + 799, + 802, + 805, + 810, + 811, + 812, + 813, + 814, + 817, + 822, + 823, + 826, + 836, + 838, + 839, + 840, + 843, + 845, + 846, + 848, + 850, + 855, + 859, + 860, + 862, + 864, + 870, + 871, + 872, + 873, + 874, + 875, + 877, + 878, + 879, + 881, + 886, + 887, + 892, + 894, + 895, + 897, + 898, + 900, + 901, + 910, + 913, + 918, + 919, + 921, + 922, + 924, + 930, + 934, + 935, + 936, + 937, + 940, + 942, + 947, + 948, + 949, + 952, + 957, + 958, + 961, + 964, + 967, + 968, + 969, + 970, + 973, + 974, + 978, + 979, + 980, + 983, + 987, + 988, + 995, + 998 + ], + "operator": "none", + "field": null, + "additionalValues": { + "salt": "118e1b3e-3454-4311-b40a-4fcdf3539c35" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 99, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "layerAssignment", + "salt": "", + "isDeviceBased": false, + "idType": "userID" + }, + { + "name": "2B3nzOtBrlt32sH5nGffRl", + "groupName": "Control", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": 500, + "operator": "lt", + "field": null, + "additionalValues": { + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 12, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "2B3nzOtBrlt32sH5nGffRl", + "salt": "2B3nzOtBrlt32sH5nGffRl", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true + }, + { + "name": "2B3nzQ8DTDCxlSf0YOaTan", + "groupName": "Test", + "passPercentage": 100, + "conditions": [ + { + "type": "user_bucket", + "targetValue": 1000, + "operator": "lt", + "field": null, + "additionalValues": { + "salt": "843e8fb2-e024-4b65-9831-68753afd71fb" + }, + "isDeviceBased": false, + "idType": "userID" + } + ], + "returnValue": { + "an_int": 8, + "a_string": "value", + "a_double": 3.1, + "a_long": 10000000000000, + "a_bool": true, + "an_array": [], + "an_object": {} + }, + "id": "2B3nzQ8DTDCxlSf0YOaTan", + "salt": "2B3nzQ8DTDCxlSf0YOaTan", + "isDeviceBased": false, + "idType": "userID", + "isExperimentGroup": true + } + ], + "isDeviceBased": false, + "idType": "userID", + "entity": "experiment", + "isActive": false, + "hasSharedParams": true, + "explicitParameters": [ + "an_int" + ] + } + ], + "feature_gates": [], + "layer_configs": [], + "has_updates": true, + "time": 1631638014811 +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e064026..4b0c53d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,10 +12,13 @@ import { DataAdapterKey, IDataAdapter, } from './interfaces/IDataAdapter'; +import { UserPersistedValues } from './interfaces/IUserPersistentStorage'; import Layer from './Layer'; import OutputLogger from './OutputLogger'; import StatsigInstanceUtils from './StatsigInstanceUtils'; import { + GetExperimentOptions, + GetLayerOptions, InitStrategy, RulesUpdatedCallback, StatsigEnvironment, @@ -34,6 +37,8 @@ export type { RulesUpdatedCallback, StatsigEnvironment, StatsigOptions, + GetExperimentOptions, + GetLayerOptions, StatsigUser, }; @@ -193,11 +198,20 @@ export const Statsig = { * * @param {StatsigUser} user - the user to evaluate for the experiment * @param {string} experimentName - the name of the experiment to get + * @param {GetExperimentOptions} options - options for experiment evaluation * @returns {DynamicConfig} - the experiment for the user, represented by a Dynamic Config object * @throws Error if initialize() was not called first */ - getExperimentSync(user: StatsigUser, experimentName: string): DynamicConfig { - return this._enforceServer().getExperimentSync(user, experimentName); + getExperimentSync( + user: StatsigUser, + experimentName: string, + options?: GetExperimentOptions, + ): DynamicConfig { + return this._enforceServer().getExperimentSync( + user, + experimentName, + options, + ); }, /** @@ -206,16 +220,19 @@ export const Statsig = { * * @param {StatsigUser} user - the user to evaluate for the experiment * @param {string} experimentName - the name of the experiment to get + * @param {GetExperimentOptions} options - options for experiment evaluation * @returns {DynamicConfig} - the experiment for the user, represented by a Dynamic Config object * @throws Error if initialize() was not called first */ getExperimentWithExposureLoggingDisabledSync( user: StatsigUser, experimentName: string, + options?: GetExperimentOptions, ): DynamicConfig { return this._enforceServer().getExperimentWithExposureLoggingDisabledSync( user, experimentName, + options, ); }, @@ -248,11 +265,16 @@ export const Statsig = { * * @param {StatsigUser} user - the user to evaluate for the layer * @param {string} layerName - the name of the layer to get + * @param {GetLayerOptions} options - options for layer evaluation * @returns {Layer} - the layer for the user, represented by a Layer * @throws Error if initialize() was not called first */ - getLayerSync(user: StatsigUser, layerName: string): Layer { - return this._enforceServer().getLayerSync(user, layerName); + getLayerSync( + user: StatsigUser, + layerName: string, + options?: GetLayerOptions, + ): Layer { + return this._enforceServer().getLayerSync(user, layerName, options); }, /** @@ -261,6 +283,7 @@ export const Statsig = { * * @param {StatsigUser} user - the user to evaluate for the layer * @param {string} layerName - the name of the layer to get + * @param {GetLayerOptions} options - options for layer evaluation * @returns {Layer} - the layer for the user, represented by a Layer * @throws Error if initialize() was not called first * @throws Error if the layerName is not provided or not a non-empty string @@ -268,10 +291,12 @@ export const Statsig = { getLayerWithExposureLoggingDisabledSync( user: StatsigUser, layerName: string, + options?: GetLayerOptions, ): Layer { return this._enforceServer().getLayerWithExposureLoggingDisabledSync( user, layerName, + options, ); }, @@ -294,6 +319,13 @@ export const Statsig = { ); }, + getUserPersistedValues( + user: StatsigUser, + idType: string, + ): UserPersistedValues { + return this._enforceServer().getUserPersistedValues(user, idType); + }, + /** * Log an event for data analysis and alerting or to measure the impact of an experiment * diff --git a/src/interfaces/IUserPersistentStorage.ts b/src/interfaces/IUserPersistentStorage.ts new file mode 100644 index 0000000..8b6d7f4 --- /dev/null +++ b/src/interfaces/IUserPersistentStorage.ts @@ -0,0 +1,45 @@ +import { SecondaryExposure } from '../LogEvent'; + +// The properties of this struct must fit a universal schema that +// when JSON-ified, can be parsed by every SDK supporting user persistent evaluation. +export type StickyValues = { + value: boolean; + json_value: Record; + rule_id: string; + group_name: string | null; + secondary_exposures: SecondaryExposure[]; + undelegated_secondary_exposures: SecondaryExposure[]; + config_delegate: string | null; + explicit_parameters: string[] | null; + time: number; +}; + +export type UserPersistedValues = Record; + +/** + * An adapter for implementing custom storage of config specs. + * Useful for backing up data in memory. + * Can also be used to bootstrap Statsig server. + */ +export interface IUserPersistentStorage { + /** + * Returns the full map of persisted values for a specific user key + * @param key user key + */ + load(key: string): UserPersistedValues; + + /** + * Save the persisted values of a config given a specific user key + * @param key user key + * @param configName Name of the config/experiment + * @param data Object representing the persistent assignment to store for the given user-config + */ + save(key: string, configName: string, data: StickyValues): void; + + /** + * Delete the persisted values of a config given a specific user key + * @param key user key + * @param configName Name of the config/experiment + */ + delete(key: string, configName: string): void; +} diff --git a/src/utils/EvaluatorUtils.ts b/src/utils/EvaluatorUtils.ts index c67a36d..6cde76f 100644 --- a/src/utils/EvaluatorUtils.ts +++ b/src/utils/EvaluatorUtils.ts @@ -80,10 +80,10 @@ export function getFromEnvironment(user: StatsigUser, field: string) { return getParameterCaseInsensitive(user?.statsigEnvironment, field); } -export function getParameterCaseInsensitive( - object: Record | undefined | null, +export function getParameterCaseInsensitive( + object: Record | undefined | null, key: string, -): unknown | undefined { +): T | undefined { if (object == null) { return undefined; } diff --git a/src/utils/StatsigContext.ts b/src/utils/StatsigContext.ts index 6e10b87..cbd6849 100644 --- a/src/utils/StatsigContext.ts +++ b/src/utils/StatsigContext.ts @@ -1,4 +1,5 @@ import { ConfigSpec } from '../ConfigSpec'; +import { UserPersistedValues } from '../interfaces/IUserPersistentStorage'; import { StatsigUser } from '../StatsigUser'; type Context = { @@ -11,6 +12,7 @@ type Context = { targetAppID?: string; user?: StatsigUser; spec?: ConfigSpec; + userPersistedValues?: UserPersistedValues | null; }; export class StatsigContext { @@ -20,6 +22,7 @@ export class StatsigContext { readonly clientKey?: string; readonly hash?: string; readonly bypassDedupe?: boolean; + readonly userPersistedValues?: UserPersistedValues | null; protected constructor(protected ctx: Context) { this.caller = ctx.caller; @@ -28,6 +31,7 @@ export class StatsigContext { this.clientKey = ctx.clientKey; this.hash = ctx.clientKey; this.bypassDedupe = ctx.bypassDedupe; + this.userPersistedValues = ctx.userPersistedValues; } // Create a new context to avoid modifying context up the stack