From d3a0f0c465e9bcb772b8dd540783ced5c9cb04cb Mon Sep 17 00:00:00 2001 From: Daniel <95646168+daniel-statsig@users.noreply.github.com> Date: Wed, 16 Nov 2022 10:50:16 -0800 Subject: [PATCH] Add manual exposure APIs (#223) * Add manual exposure APIs * Address feedback --- jest-debug.config.js | 6 + package.json | 3 +- src/Layer.ts | 49 ++--- src/StatsigClient.ts | 295 ++++++++++++++++++++++---- src/StatsigLogger.ts | 46 ++-- src/StatsigStore.ts | 57 ++--- src/__tests__/ExposureLogging.test.ts | 164 ++++++++++++++ src/__tests__/InternalStore.test.ts | 50 +++-- src/index.ts | 69 ++++++ 9 files changed, 600 insertions(+), 139 deletions(-) create mode 100644 jest-debug.config.js create mode 100644 src/__tests__/ExposureLogging.test.ts diff --git a/jest-debug.config.js b/jest-debug.config.js new file mode 100644 index 0000000..e42e5c9 --- /dev/null +++ b/jest-debug.config.js @@ -0,0 +1,6 @@ +const base = require('./jest.config'); + +module.exports = { + ...base, + ...{ transform: undefined }, +}; diff --git a/package.json b/package.json index 3346e22..daf6527 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "prepare": "rm -rf build/ && rm -rf dist/ && tsc && webpack", "postbuild": "rm -rf build/**/*.map", "test": "jest", + "test:debug": "jest --config=jest-debug.config.js", "test:watch": "jest --watch", "build:dryrun": "npx tsc --noEmit", "types": "npx tsc" @@ -63,4 +64,4 @@ "parser": "typescript" } } -} +} \ No newline at end of file diff --git a/src/Layer.ts b/src/Layer.ts index 10c84f1..91f0205 100644 --- a/src/Layer.ts +++ b/src/Layer.ts @@ -1,8 +1,12 @@ -import { IHasStatsigInternal } from './StatsigClient'; import { EvaluationDetails } from './StatsigStore'; +export type LogParameterFunction = ( + layer: Layer, + parameterName: string, +) => void; + export default class Layer { - private sdkInternal: IHasStatsigInternal | null; + private logParameterFunction: LogParameterFunction | null; private name: string; private value: Record; private ruleID: string; @@ -17,13 +21,13 @@ export default class Layer { layerValue: Record, ruleID: string, evaluationDetails: EvaluationDetails, - sdkInternal: IHasStatsigInternal | null = null, + logParameterFunction: LogParameterFunction | null = null, secondaryExposures: Record[] = [], undelegatedSecondaryExposures: Record[] = [], allocatedExperimentName: string = '', explicitParameters: string[] = [], ) { - this.sdkInternal = sdkInternal; + this.logParameterFunction = logParameterFunction; this.name = name; this.value = JSON.parse(JSON.stringify(layerValue ?? {})); this.ruleID = ruleID ?? ''; @@ -39,7 +43,7 @@ export default class Layer { value: Record, ruleID: string, evaluationDetails: EvaluationDetails, - sdkInternal: IHasStatsigInternal | null = null, + logParameterFunction: LogParameterFunction | null = null, secondaryExposures: Record[] = [], undelegatedSecondaryExposures: Record[] = [], allocatedExperimentName: string = '', @@ -50,7 +54,7 @@ export default class Layer { value, ruleID, evaluationDetails, - sdkInternal, + logParameterFunction, secondaryExposures, undelegatedSecondaryExposures, allocatedExperimentName, @@ -124,30 +128,23 @@ export default class Layer { return this.secondaryExposures; } + public _getUndelegatedSecondaryExposures(): Record[] { + return this.undelegatedSecondaryExposures; + } + public _getAllocatedExperimentName(): string { return this.allocatedExperimentName; } - private logLayerParameterExposure(parameterName: string) { - let allocatedExperiment = ''; - let exposures = this.undelegatedSecondaryExposures; - const isExplicit = this.explicitParameters.includes(parameterName); - if (isExplicit) { - allocatedExperiment = this.allocatedExperimentName; - exposures = this.secondaryExposures; - } + public _getExplicitParameters(): string[] { + return this.explicitParameters; + } - this.sdkInternal - ?.getLogger() - .logLayerExposure( - this.sdkInternal.getCurrentUser(), - this.name, - this.ruleID, - exposures, - allocatedExperiment, - parameterName, - isExplicit, - this.evaluationDetails, - ); + public _getEvaluationDetails(): EvaluationDetails { + return this.evaluationDetails; + } + + private logLayerParameterExposure(parameterName: string) { + this.logParameterFunction?.(this, parameterName); } } diff --git a/src/StatsigClient.ts b/src/StatsigClient.ts index feb56df..4c2912f 100644 --- a/src/StatsigClient.ts +++ b/src/StatsigClient.ts @@ -4,9 +4,8 @@ import { StatsigInvalidArgumentError, StatsigUninitializedError, } from './Errors'; -import Layer from './Layer'; +import Layer, { LogParameterFunction } from './Layer'; import LogEvent from './LogEvent'; -import StatsigIdentity, { UUID } from './StatsigIdentity'; import type { DeviceInfo, ExpoConstants, @@ -14,17 +13,19 @@ import type { NativeModules, Platform, } from './StatsigIdentity'; +import StatsigIdentity, { UUID } from './StatsigIdentity'; import StatsigLogger from './StatsigLogger'; import StatsigNetwork from './StatsigNetwork'; import StatsigSDKOptions, { StatsigOptions } from './StatsigSDKOptions'; import StatsigStore, { EvaluationDetails, EvaluationReason, + StoreGateFetchResult, } from './StatsigStore'; import { StatsigUser } from './StatsigUser'; import { getUserCacheKey } from './utils/Hashing'; -import StatsigAsyncStorage from './utils/StatsigAsyncStorage'; import type { AsyncStorage } from './utils/StatsigAsyncStorage'; +import StatsigAsyncStorage from './utils/StatsigAsyncStorage'; import StatsigLocalStorage from './utils/StatsigLocalStorage'; import { difference, now } from './utils/Timing'; import Diagnostics, { @@ -331,18 +332,32 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig { return this.errorBoundary.capture( 'checkGate', () => { - this.ensureStoreLoaded(); - if (typeof gateName !== 'string' || gateName.length === 0) { - throw new StatsigInvalidArgumentError( - 'Must pass a valid string as the gateName.', - ); - } - return this.store.checkGate(gateName, ignoreOverrides); + const result = this.checkGateImpl(gateName, ignoreOverrides); + this.logGateExposureImpl(gateName, result); + return result.gate.value === true; + }, + () => false, + ); + } + + public checkGateWithExposureLoggingDisabled( + gateName: string, + ignoreOverrides: boolean = false, + ): boolean { + return this.errorBoundary.capture( + 'checkGateWithExposureLoggingDisabled', + () => { + const result = this.checkGateImpl(gateName, ignoreOverrides); + return result.gate.value === true; }, () => false, ); } + public logGateExposure(gateName: string) { + this.logGateExposureImpl(gateName); + } + /** * Checks the value of a config for the current user * @param {string} configName - the name of the config to get @@ -357,25 +372,31 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig { return this.errorBoundary.capture( 'getConfig', () => { - this.ensureStoreLoaded(); - if (typeof configName !== 'string' || configName.length === 0) { - throw new StatsigInvalidArgumentError( - 'Must pass a valid string as the configName.', - ); - } + const result = this.getConfigImpl(configName, ignoreOverrides); + this.logConfigExposureImpl(configName, result); + return result; + }, + () => this.getEmptyConfig(configName), + ); + } - return this.store.getConfig(configName, ignoreOverrides); + public getConfigWithExposureLoggingDisabled( + configName: string, + ignoreOverrides: boolean = false, + ): DynamicConfig { + return this.errorBoundary.capture( + 'getConfig', + () => { + return this.getConfigImpl(configName, ignoreOverrides); }, - () => - new DynamicConfig( - configName, - {}, - '', - this.getEvalutionDetailsForError(), - ), + () => this.getEmptyConfig(configName), ); } + public logConfigExposure(configName: string) { + this.logConfigExposureImpl(configName); + } + /** * Gets the experiment for a given user * @param {string} experimentName - the name of the experiment to get @@ -392,46 +413,81 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig { return this.errorBoundary.capture( 'getExperiment', () => { - this.ensureStoreLoaded(); - if (typeof experimentName !== 'string' || experimentName.length === 0) { - throw new StatsigInvalidArgumentError( - 'Must pass a valid string as the experimentName.', - ); - } - return this.store.getExperiment( + const result = this.getExperimentImpl( experimentName, keepDeviceValue, ignoreOverrides, ); + this.logExperimentExposureImpl(experimentName, keepDeviceValue, result); + return result; }, - () => - new DynamicConfig( + () => this.getEmptyConfig(experimentName), + ); + } + + public getExperimentWithExposureLoggingDisabled( + experimentName: string, + keepDeviceValue: boolean = false, + ignoreOverrides: boolean = false, + ): DynamicConfig { + return this.errorBoundary.capture( + 'getExperimentWithExposureLoggingDisabled', + () => { + return this.getExperimentImpl( experimentName, - {}, - '', - this.getEvalutionDetailsForError(), - ), + keepDeviceValue, + ignoreOverrides, + ); + }, + () => this.getEmptyConfig(experimentName), ); } + public logExperimentExposure( + experimentName: string, + keepDeviceValue: boolean, + ) { + this.logExperimentExposureImpl(experimentName, keepDeviceValue); + } + public getLayer(layerName: string, keepDeviceValue: boolean = false): Layer { return this.errorBoundary.capture( 'getLayer', () => { - this.ensureStoreLoaded(); - if (typeof layerName !== 'string' || layerName.length === 0) { - throw new StatsigInvalidArgumentError( - 'Must pass a valid string as the layerName.', - ); - } + return this.getLayerImpl( + this.logLayerParameterExposureForLayer, + layerName, + keepDeviceValue, + ); + }, + () => + Layer._create(layerName, {}, '', this.getEvalutionDetailsForError()), + ); + } - return this.store.getLayer(layerName, keepDeviceValue); + public getLayerWithExposureLoggingDisabled( + layerName: string, + keepDeviceValue: boolean = false, + ): Layer { + return this.errorBoundary.capture( + 'getLayerWithExposureLoggingDisabled', + () => { + return this.getLayerImpl(null, layerName, keepDeviceValue); }, () => Layer._create(layerName, {}, '', this.getEvalutionDetailsForError()), ); } + public logLayerParameterExposure( + layerName: string, + parameterName: string, + keepDeviceValue: boolean = false, + ) { + const layer = this.getLayerImpl(null, layerName, keepDeviceValue); + this.logLayerParameterExposureForLayer(layer, parameterName, true); + } + public logEvent( eventName: string, value: string | number | null = null, @@ -897,4 +953,155 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig { completionCallback?.(false, e.message); }); } + + private checkGateImpl( + gateName: string, + ignoreOverrides: boolean, + ): StoreGateFetchResult { + this.ensureStoreLoaded(); + if (typeof gateName !== 'string' || gateName.length === 0) { + throw new StatsigInvalidArgumentError( + 'Must pass a valid string as the gateName.', + ); + } + return this.store.checkGate(gateName, ignoreOverrides); + } + + private logGateExposureImpl( + gateName: string, + fetchResult?: StoreGateFetchResult, + ) { + const isManualExposure = !fetchResult; + const result = fetchResult ?? this.checkGateImpl(gateName, false); + const gate = result.gate; + + this.logger.logGateExposure( + this.getCurrentUser(), + gateName, + gate.value, + gate.rule_id, + gate.secondary_exposures, + result.evaluationDetails, + isManualExposure, + ); + } + + private getConfigImpl( + configName: string, + ignoreOverrides: boolean, + ): DynamicConfig { + this.ensureStoreLoaded(); + if (typeof configName !== 'string' || configName.length === 0) { + throw new StatsigInvalidArgumentError( + 'Must pass a valid string as the configName.', + ); + } + + return this.store.getConfig(configName, ignoreOverrides); + } + + private logConfigExposureImpl(configName: string, config?: DynamicConfig) { + const isManualExposure = !config; + const localConfig = config ?? this.getConfigImpl(configName, false); + + this.logger.logConfigExposure( + this.getCurrentUser(), + configName, + localConfig.getRuleID(), + localConfig._getSecondaryExposures(), + localConfig.getEvaluationDetails(), + isManualExposure, + ); + } + + private getExperimentImpl( + experimentName: string, + keepDeviceValue: boolean, + ignoreOverrides: boolean, + ) { + this.ensureStoreLoaded(); + if (typeof experimentName !== 'string' || experimentName.length === 0) { + throw new StatsigInvalidArgumentError( + 'Must pass a valid string as the experimentName.', + ); + } + return this.store.getExperiment( + experimentName, + keepDeviceValue, + ignoreOverrides, + ); + } + + private logExperimentExposureImpl( + experimentName: string, + keepDeviceValue: boolean, + config?: DynamicConfig, + ) { + const isManualExposure = !config; + const localConfig = + config ?? this.getExperimentImpl(experimentName, keepDeviceValue, false); + + this.logger.logConfigExposure( + this.getCurrentUser(), + experimentName, + localConfig.getRuleID(), + localConfig._getSecondaryExposures(), + localConfig.getEvaluationDetails(), + isManualExposure, + ); + } + + private getLayerImpl( + logParameterFunction: LogParameterFunction | null, + layerName: string, + keepDeviceValue: boolean, + ): Layer { + this.ensureStoreLoaded(); + if (typeof layerName !== 'string' || layerName.length === 0) { + throw new StatsigInvalidArgumentError( + 'Must pass a valid string as the layerName.', + ); + } + + return this.store.getLayer( + logParameterFunction, + layerName, + keepDeviceValue, + ); + } + + private logLayerParameterExposureForLayer = ( + layer: Layer, + parameterName: string, + isManualExposure: boolean = false, + ) => { + let allocatedExperiment = ''; + let exposures = layer._getUndelegatedSecondaryExposures(); + const isExplicit = layer._getExplicitParameters().includes(parameterName); + if (isExplicit) { + allocatedExperiment = layer._getAllocatedExperimentName(); + exposures = layer._getSecondaryExposures(); + } + + this.logger.logLayerExposure( + this.getCurrentUser(), + layer.getName(), + layer.getRuleID(), + exposures, + allocatedExperiment, + parameterName, + isExplicit, + layer._getEvaluationDetails(), + isManualExposure, + ); + }; + + private getEmptyConfig(configName: string): DynamicConfig { + return new DynamicConfig( + configName, + {}, + '', + this.getEvalutionDetailsForError(), + ); + } } diff --git a/src/StatsigLogger.ts b/src/StatsigLogger.ts index 31b2acf..372a1bb 100644 --- a/src/StatsigLogger.ts +++ b/src/StatsigLogger.ts @@ -146,20 +146,28 @@ export default class StatsigLogger { ruleID: string, secondaryExposures: Record[], details: EvaluationDetails, + isManualExposure: boolean, ) { const dedupeKey = gateName + String(gateValue) + ruleID + details.reason; if (!this.shouldLogExposure(dedupeKey)) { return; } - const gateExposure = new LogEvent(GATE_EXPOSURE_EVENT); - gateExposure.setUser(user); - gateExposure.setMetadata({ + + const metadata: Record = { gate: gateName, gateValue: String(gateValue), ruleID: ruleID, reason: details.reason, time: details.time, - }); + }; + + if (isManualExposure) { + metadata['isManualExposure'] = 'true'; + } + + const gateExposure = new LogEvent(GATE_EXPOSURE_EVENT); + gateExposure.setUser(user); + gateExposure.setMetadata(metadata); gateExposure.setSecondaryExposures(secondaryExposures); this.log(gateExposure); } @@ -170,20 +178,27 @@ export default class StatsigLogger { ruleID: string, secondaryExposures: Record[], details: EvaluationDetails, + isManualExposure: boolean, ) { const dedupeKey = configName + ruleID + details.reason; if (!this.shouldLogExposure(dedupeKey)) { return; } - const configExposure = new LogEvent(CONFIG_EXPOSURE_EVENT); - configExposure.setUser(user); - configExposure.setMetadata({ + const metadata: Record = { config: configName, ruleID: ruleID, reason: details.reason, time: details.time, - }); + }; + + if (isManualExposure) { + metadata['isManualExposure'] = 'true'; + } + + const configExposure = new LogEvent(CONFIG_EXPOSURE_EVENT); + configExposure.setUser(user); + configExposure.setMetadata(metadata); configExposure.setSecondaryExposures(secondaryExposures); this.log(configExposure); } @@ -197,6 +212,7 @@ export default class StatsigLogger { parameterName: string, isExplicitParameter: boolean, details: EvaluationDetails, + isManualExposure: boolean, ) { const dedupeKey = [ configName, @@ -211,9 +227,7 @@ export default class StatsigLogger { return; } - const configExposure = new LogEvent(LAYER_EXPOSURE_EVENT); - configExposure.setUser(user); - configExposure.setMetadata({ + const metadata: Record = { config: configName, ruleID: ruleID, allocatedExperiment, @@ -221,7 +235,15 @@ export default class StatsigLogger { isExplicitParameter: String(isExplicitParameter), reason: details.reason, time: details.time, - }); + }; + + if (isManualExposure) { + metadata['isManualExposure'] = 'true'; + } + + const configExposure = new LogEvent(LAYER_EXPOSURE_EVENT); + configExposure.setUser(user); + configExposure.setMetadata(metadata); configExposure.setSecondaryExposures(secondaryExposures); this.log(configExposure); } diff --git a/src/StatsigStore.ts b/src/StatsigStore.ts index 885857f..1294ae8 100644 --- a/src/StatsigStore.ts +++ b/src/StatsigStore.ts @@ -1,5 +1,5 @@ import DynamicConfig from './DynamicConfig'; -import Layer from './Layer'; +import Layer, { LogParameterFunction } from './Layer'; import { IHasStatsigInternal, StatsigOverrides } from './StatsigClient'; import BootstrapValidator from './utils/BootstrapValidator'; import { StatsigUser } from './StatsigUser'; @@ -37,6 +37,11 @@ type APIFeatureGate = { secondary_exposures: []; }; +export type StoreGateFetchResult = { + gate: APIFeatureGate; + evaluationDetails: EvaluationDetails; +}; + type APIDynamicConfig = { name: string; value: { [key: string]: unknown }; @@ -289,12 +294,18 @@ export default class StatsigStore { public checkGate( gateName: string, ignoreOverrides: boolean = false, - ): boolean { + ): StoreGateFetchResult { const gateNameHash = getHashValue(gateName); - let gateValue = { value: false, rule_id: '', secondary_exposures: [] }; + let gateValue: APIFeatureGate = { + name: gateName, + value: false, + rule_id: '', + secondary_exposures: [], + }; let details: EvaluationDetails; if (!ignoreOverrides && this.overrides.gates[gateName] != null) { gateValue = { + name: gateName, value: this.overrides.gates[gateName], rule_id: 'override', secondary_exposures: [], @@ -310,17 +321,8 @@ export default class StatsigStore { } details = this.getEvaluationDetails(value != null); } - this.sdkInternal - .getLogger() - .logGateExposure( - this.sdkInternal.getCurrentUser(), - gateName, - gateValue.value, - gateValue.rule_id, - gateValue.secondary_exposures, - details, - ); - return gateValue.value === true; + + return { evaluationDetails: details, gate: gateValue }; } public getConfig( @@ -353,15 +355,7 @@ export default class StatsigStore { details = this.getEvaluationDetails(false); configValue = new DynamicConfig(configName, {}, '', details); } - this.sdkInternal - .getLogger() - .logConfigExposure( - this.sdkInternal.getCurrentUser(), - configName, - configValue.getRuleID(), - configValue._getSecondaryExposures(), - details, - ); + return configValue; } @@ -397,19 +391,14 @@ export default class StatsigStore { exp = this.createDynamicConfig(expName, finalValue, details); } - this.sdkInternal - .getLogger() - .logConfigExposure( - this.sdkInternal.getCurrentUser(), - expName, - exp.getRuleID(), - exp._getSecondaryExposures(), - details, - ); return exp; } - public getLayer(layerName: string, keepDeviceValue: boolean): Layer { + public getLayer( + logParameterFunction: LogParameterFunction | null, + layerName: string, + keepDeviceValue: boolean, + ): Layer { const latestValue = this.getLatestValue(layerName, 'layer_configs'); const details = this.getEvaluationDetails(latestValue != null); const finalValue = this.getPossiblyStickyValue( @@ -425,7 +414,7 @@ export default class StatsigStore { finalValue?.value ?? {}, finalValue?.rule_id ?? '', details, - this.sdkInternal, + logParameterFunction, finalValue?.secondary_exposures, finalValue?.undelegated_secondary_exposures, finalValue?.allocated_experiment_name ?? '', diff --git a/src/__tests__/ExposureLogging.test.ts b/src/__tests__/ExposureLogging.test.ts new file mode 100644 index 0000000..be8eaef --- /dev/null +++ b/src/__tests__/ExposureLogging.test.ts @@ -0,0 +1,164 @@ +/** + * @jest-environment jsdom + */ + +import Statsig from '..'; +import { getHashValue } from '../utils/Hashing'; + +describe('ExposureLogging', () => { + let events: { + eventName: string; + metadata: { gate?: string; config?: string; isManualExposure?: string }; + }[] = []; + + beforeEach(async () => { + // @ts-ignore + global.fetch = jest.fn((url, params) => { + if (url.toString().includes('rgstr')) { + const newEvents: typeof events = JSON.parse(params?.body as string)[ + 'events' + ]; + events.push(...newEvents); + } + + return Promise.resolve({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + feature_gates: { + [getHashValue('a_gate')]: { + value: true, + }, + }, + dynamic_configs: { + [getHashValue('an_experiment')]: { + value: { a_bool: true }, + }, + [getHashValue('a_config')]: { + value: { a_bool: true }, + }, + }, + layer_configs: { + [getHashValue('a_layer')]: { + value: { a_bool: true }, + }, + }, + sdkParams: {}, + has_updates: true, + time: 1647984444418, + }), + ), + }); + }); + + events = []; + + // @ts-ignore + Statsig.instance = null; + await Statsig.initialize( + 'client-key', + { userID: 'dloomb' }, + { initTimeoutMs: 1 }, + ); + + // @ts-ignore + Statsig.instance.options.loggingBufferMaxSize = 1; + }); + + afterEach(() => { + Statsig.shutdown(); + }); + + describe('standard use', () => { + it('logs gate exposures', async () => { + Statsig.checkGate('a_gate'); + expect(events.length).toBe(1); + expect(events[0].metadata.gate).toEqual('a_gate'); + expect(events[0].metadata.isManualExposure).toBeUndefined(); + expect(events[0].eventName).toEqual('statsig::gate_exposure'); + }); + + it('logs config exposures', async () => { + Statsig.getConfig('a_config'); + expect(events.length).toBe(1); + expect(events[0].metadata.config).toEqual('a_config'); + expect(events[0].metadata.isManualExposure).toBeUndefined(); + expect(events[0].eventName).toEqual('statsig::config_exposure'); + }); + + it('logs experiment exposures', async () => { + Statsig.getExperiment('an_experiment'); + expect(events.length).toBe(1); + expect(events[0].metadata.config).toEqual('an_experiment'); + expect(events[0].metadata.isManualExposure).toBeUndefined(); + expect(events[0].eventName).toEqual('statsig::config_exposure'); + }); + + it('logs layer exposures', async () => { + const layer = Statsig.getLayer('a_layer'); + layer.get('a_bool', false); + expect(events.length).toBe(1); + expect(events[0].metadata.config).toEqual('a_layer'); + expect(events[0].metadata.isManualExposure).toBeUndefined(); + expect(events[0].eventName).toEqual('statsig::layer_exposure'); + }); + }); + + describe('exposure logging disabled', () => { + it('does not log gate exposures', async () => { + Statsig.checkGateWithExposureLoggingDisabled('a_gate'); + expect(events.length).toBe(0); + }); + + it('does not log config exposures', async () => { + Statsig.getConfigWithExposureLoggingDisabled('a_config'); + expect(events.length).toBe(0); + }); + + it('does not log experiment exposures', async () => { + Statsig.getExperimentWithExposureLoggingDisabled('an_experiment'); + expect(events.length).toBe(0); + }); + + it('does not log layer exposures', async () => { + const layer = Statsig.getLayerWithExposureLoggingDisabled('a_layer'); + layer.get('a_bool', false); + expect(events.length).toBe(0); + }); + }); + + describe('manual exposure logging', () => { + it('logs a manual gate exposure', async () => { + Statsig.manuallyLogGateExposure('a_gate'); + expect(events.length).toBe(1); + expect(events[0].metadata.gate).toEqual('a_gate'); + expect(events[0].metadata.isManualExposure).toEqual('true'); + expect(events[0].eventName).toEqual('statsig::gate_exposure'); + }); + + it('logs a manual config exposure', async () => { + Statsig.manuallyLogConfigExposure('a_config'); + expect(events.length).toBe(1); + expect(events[0].metadata.config).toEqual('a_config'); + expect(events[0].metadata.isManualExposure).toEqual('true'); + expect(events[0].eventName).toEqual('statsig::config_exposure'); + }); + + it('logs a manual experiment exposure', async () => { + Statsig.manuallyLogExperimentExposure('an_experiment'); + expect(events.length).toBe(1); + expect(events[0].metadata.config).toEqual('an_experiment'); + expect(events[0].metadata.isManualExposure).toEqual('true'); + expect(events[0].eventName).toEqual('statsig::config_exposure'); + }); + + it('logs a manual layer exposure', async () => { + Statsig.manuallyLogLayerParameterExposure('a_layer', 'a_bool'); + expect(events.length).toBe(1); + expect(events[0].metadata.config).toEqual('a_layer'); + expect(events[0].metadata.isManualExposure).toEqual('true'); + expect(events[0].eventName).toEqual('statsig::layer_exposure'); + }); + }); +}); diff --git a/src/__tests__/InternalStore.test.ts b/src/__tests__/InternalStore.test.ts index a58def6..05cf414 100644 --- a/src/__tests__/InternalStore.test.ts +++ b/src/__tests__/InternalStore.test.ts @@ -168,7 +168,7 @@ describe('Verify behavior of InternalStore', () => { reason: EvaluationReason.Network, time: now, }); - expect(store.checkGate('test_gate', false)).toEqual(true); + expect(store.checkGate('test_gate', false).gate.value).toEqual(true); }); test('Verify cache before init and save correctly saves into cache.', () => { @@ -194,41 +194,40 @@ describe('Verify behavior of InternalStore', () => { reason: EvaluationReason.Network, time: now, }); - expect(store.checkGate('test_gate', false)).toEqual(true); + expect(store.checkGate('test_gate', false).gate.value).toEqual(true); }); test('Verify checkGate returns false when gateName does not exist.', () => { - expect.assertions(2); + expect.assertions(1); const client = new StatsigClient(sdkKey, { userID: 'user_key' }); return client.initializeAsync().then(() => { const store = client.getStore(); - const spy = jest.spyOn(client.getLogger(), 'log'); - expect(store.checkGate('fake_gate', false)).toBe(false); - expect(spy).toHaveBeenCalledTimes(1); + const result = store.checkGate('fake_gate', false).gate.value; + expect(result).toBe(false); }); }); test('Verify checkGate returns the correct value.', () => { - expect.assertions(3); + expect.assertions(2); const client = new StatsigClient(sdkKey, { userID: 'user_key' }); return client.initializeAsync().then(() => { - const spy = jest.spyOn(client.getLogger(), 'log'); - expect(client.getStore().checkGate('test_gate', false)).toBe(true); + expect(client.getStore().checkGate('test_gate', false).gate.value).toBe( + true, + ); expect( client .getStore() - .checkGate('AoZS0F06Ub+W2ONx+94rPTS7MRxuxa+GnXro5Q1uaGY=', false), + .checkGate('AoZS0F06Ub+W2ONx+94rPTS7MRxuxa+GnXro5Q1uaGY=', false).gate + .value, ).toBe(false); - expect(spy).toHaveBeenCalledTimes(2); }); }); test('Verify getConfig returns a dummy config and logs exposure when configName does not exist.', () => { - expect.assertions(5); + expect.assertions(4); const client = new StatsigClient(sdkKey, { userID: 'user_key' }); return client.initializeAsync().then(() => { const store = client.getStore(); - const spy = jest.spyOn(client.getLogger(), 'log'); const config = store.getConfig('fake_config', false); expect(config.getName()).toEqual('fake_config'); expect(config.getValue()).toEqual({}); @@ -237,20 +236,17 @@ describe('Verify behavior of InternalStore', () => { reason: EvaluationReason.Unrecognized, time: now, }); - expect(spy).toHaveBeenCalledTimes(1); }); }); test('Verify getConfig returns the correct value.', () => { - expect.assertions(2); + expect.assertions(1); const client = new StatsigClient(sdkKey, { userID: 'user_key' }); return client.initializeAsync().then(() => { const store = client.getStore(); - const spy = jest.spyOn(client.getLogger(), 'log'); expect(store.getConfig('test_config', false).getValue()).toMatchObject({ bool: true, }); - expect(spy).toHaveBeenCalledTimes(1); }); }); @@ -259,11 +255,15 @@ describe('Verify behavior of InternalStore', () => { const statsig = new StatsigClient(sdkKey, { userID: '123' }); await statsig.initializeAsync(); // test_gate is true without override - expect(statsig.getStore().checkGate('test_gate', false)).toBe(true); + expect(statsig.getStore().checkGate('test_gate', false).gate.value).toBe( + true, + ); // becomes false with override statsig.getStore().overrideGate('test_gate', false); - expect(statsig.getStore().checkGate('test_gate', false)).toBe(false); + expect(statsig.getStore().checkGate('test_gate', false).gate.value).toBe( + false, + ); expect(statsig.getOverrides()).toEqual({ test_gate: false }); // overriding non-existent gate @@ -279,7 +279,9 @@ describe('Verify behavior of InternalStore', () => { // remove a named override statsig.getStore().overrideGate('test_gate', false); - expect(statsig.getStore().checkGate('test_gate', false)).toBe(false); + expect(statsig.getStore().checkGate('test_gate', false).gate.value).toBe( + false, + ); expect(statsig.getOverrides()).toEqual({ test_gate: false }); statsig.removeOverride('test_gate'); expect(statsig.getOverrides()).toEqual({}); @@ -320,9 +322,13 @@ describe('Verify behavior of InternalStore', () => { // remove config override, add gate override statsig.removeConfigOverride(); - expect(statsig.getStore().checkGate('test_gate', false)).toBe(true); + expect(statsig.getStore().checkGate('test_gate', false).gate.value).toBe( + true, + ); statsig.getStore().overrideGate('test_gate', false); - expect(statsig.getStore().checkGate('test_gate', false)).toBe(false); + expect(statsig.getStore().checkGate('test_gate', false).gate.value).toBe( + false, + ); expect(statsig.getAllOverrides()).toEqual({ gates: { test_gate: false }, configs: {}, diff --git a/src/index.ts b/src/index.ts index 958eb1b..922c9d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,20 @@ export default class Statsig { return Statsig.getClientX().checkGate(gateName, ignoreOverrides); } + public static checkGateWithExposureLoggingDisabled( + gateName: string, + ignoreOverrides: boolean = false, + ): boolean { + return Statsig.getClientX().checkGateWithExposureLoggingDisabled( + gateName, + ignoreOverrides, + ); + } + + public static manuallyLogGateExposure(gateName: string) { + Statsig.getClientX().logGateExposure(gateName); + } + public static getConfig( configName: string, ignoreOverrides: boolean = false, @@ -94,6 +108,20 @@ export default class Statsig { return Statsig.getClientX().getConfig(configName, ignoreOverrides); } + public static getConfigWithExposureLoggingDisabled( + configName: string, + ignoreOverrides: boolean = false, + ): DynamicConfig { + return Statsig.getClientX().getConfigWithExposureLoggingDisabled( + configName, + ignoreOverrides, + ); + } + + public static manuallyLogConfigExposure(configName: string) { + Statsig.getClientX().logConfigExposure(configName); + } + public static getExperiment( experimentName: string, keepDeviceValue: boolean = false, @@ -106,6 +134,25 @@ export default class Statsig { ); } + public static getExperimentWithExposureLoggingDisabled( + experimentName: string, + keepDeviceValue: boolean = false, + ignoreOverrides: boolean = false, + ): DynamicConfig { + return Statsig.getClientX().getExperimentWithExposureLoggingDisabled( + experimentName, + keepDeviceValue, + ignoreOverrides, + ); + } + + public static manuallyLogExperimentExposure( + configName: string, + keepDeviceValue: boolean = false, + ) { + Statsig.getClientX().logExperimentExposure(configName, keepDeviceValue); + } + public static getLayer( layerName: string, keepDeviceValue: boolean = false, @@ -113,6 +160,28 @@ export default class Statsig { return Statsig.getClientX().getLayer(layerName, keepDeviceValue); } + public static getLayerWithExposureLoggingDisabled( + layerName: string, + keepDeviceValue: boolean = false, + ): Layer { + return Statsig.getClientX().getLayerWithExposureLoggingDisabled( + layerName, + keepDeviceValue, + ); + } + + public static manuallyLogLayerParameterExposure( + layerName: string, + parameterName: string, + keepDeviceValue: boolean = false, + ) { + Statsig.getClientX().logLayerParameterExposure( + layerName, + parameterName, + keepDeviceValue, + ); + } + public static logEvent( eventName: string, value: string | number | null = null,