diff --git a/packages/combo/src/__tests__/MemoryLeak.test.ts b/packages/combo/src/__tests__/MemoryLeak.test.ts index abae37bd..3c4b5f60 100644 --- a/packages/combo/src/__tests__/MemoryLeak.test.ts +++ b/packages/combo/src/__tests__/MemoryLeak.test.ts @@ -22,14 +22,12 @@ async function runGarbageCollection() { } describe('Memory Usage', () => { - beforeAll(() => { + beforeAll(async () => { fetchMock.enableMocks(); fetchMock.mockResponse(InitResponseString); MockLocalStorage.enabledMockStorage(); - }); - it('clears all used memory when done', async () => { for (let i = 0; i < 1000; i++) { const instance = new StatsigClient( 'client-key', @@ -39,6 +37,9 @@ describe('Memory Usage', () => { await instance.initializeAsync(); instance.checkGate('gate1'); } + }); + + it('clears all used memory when done', async () => { const initialMemory = process.memoryUsage().heapUsed; for (let i = 0; i < 10000; i++) { diff --git a/packages/combo/src/__tests__/ParamStoreLocalOverrides.test.ts b/packages/combo/src/__tests__/ParamStoreLocalOverrides.test.ts new file mode 100644 index 00000000..026b4dbd --- /dev/null +++ b/packages/combo/src/__tests__/ParamStoreLocalOverrides.test.ts @@ -0,0 +1,118 @@ +import fetchMock from 'jest-fetch-mock'; +import { InitResponseString } from 'statsig-test-helpers'; + +import { + ParameterStore, + StatsigClient, + StatsigEvent, + _DJB2, +} from '@statsig/js-client'; +import { LocalOverrideAdapter } from '@statsig/js-local-overrides'; + +describe('Parameter Stores - Local Overrides', () => { + let client: StatsigClient; + let store: ParameterStore; + let events: StatsigEvent[] = []; + + beforeAll(async () => { + fetchMock.enableMocks(); + fetchMock.mockResponse(InitResponseString); + + const overrideAdapter = new LocalOverrideAdapter(); + overrideAdapter.overrideGate('partial_gate', false); + overrideAdapter.overrideExperiment('three_groups', { a_num: 99 }); + overrideAdapter.overrideLayer('a_layer', { my_obj: { key: 'override' } }); + + client = new StatsigClient( + 'client-key', + { userID: 'a-user' }, + { overrideAdapter }, + ); + await client.initializeAsync(); + + fetchMock.mockImplementation(async (url, payload) => { + if (!url?.toString().includes('/rgstr')) { + return Promise.resolve(new Response()); + } + + const body = JSON.parse(String(payload?.body ?? '{}')); + events.push( + ...body.events.filter((e: StatsigEvent) => + e.eventName.includes('_exposure'), + ), + ); + return Promise.resolve(new Response()); + }); + + store = client.getParameterStore('a_param_store'); + }); + + describe('mapped gate params', () => { + let result: string; + + beforeAll(async () => { + events = []; + + result = store.get('a_string_param', 'fallback'); + await client.flush(); + }); + + it('gets the correct value', async () => { + expect(result).toEqual('Nah'); + }); + + it('logs a gate exposure event', () => { + expect(events[0].eventName).toEqual('statsig::gate_exposure'); + expect(events[0].metadata?.['gate']).toEqual(_DJB2('partial_gate')); + expect(events[0].metadata?.['reason']).toEqual( + 'LocalOverride:Recognized', + ); + }); + }); + + describe('mapped experiment params', () => { + let result: number; + + beforeAll(async () => { + events = []; + + result = store.get('a_num_param', -1); + await client.flush(); + }); + + it('gets the correct value', async () => { + expect(result).toBe(99); + }); + + it('logs a gate exposure event', () => { + expect(events[0].eventName).toEqual('statsig::config_exposure'); + expect(events[0].metadata?.['config']).toEqual(_DJB2('three_groups')); + expect(events[0].metadata?.['reason']).toEqual( + 'LocalOverride:Recognized', + ); + }); + }); + + describe('mapped layer params', () => { + let result: object; + + beforeAll(async () => { + events = []; + + result = store.get('an_object_param', { key: 'fallback' }); + await client.flush(); + }); + + it('gets the correct value', async () => { + expect(result).toEqual({ key: 'override' }); + }); + + it('logs a gate exposure event', () => { + expect(events[0].eventName).toEqual('statsig::layer_exposure'); + expect(events[0].metadata?.['config']).toEqual(_DJB2('a_layer')); + expect(events[0].metadata?.['reason']).toEqual( + 'LocalOverride:Recognized', + ); + }); + }); +}); diff --git a/packages/combo/src/__tests__/ParamStores.test.ts b/packages/combo/src/__tests__/ParamStores.test.ts new file mode 100644 index 00000000..68e1a52f --- /dev/null +++ b/packages/combo/src/__tests__/ParamStores.test.ts @@ -0,0 +1,102 @@ +import fetchMock from 'jest-fetch-mock'; +import { InitResponseString } from 'statsig-test-helpers'; + +import { + ParameterStore, + StatsigClient, + StatsigEvent, + _DJB2, +} from '@statsig/js-client'; + +describe('Parameter Stores', () => { + let client: StatsigClient; + let store: ParameterStore; + let events: StatsigEvent[] = []; + + beforeAll(async () => { + fetchMock.enableMocks(); + fetchMock.mockResponse(InitResponseString); + + client = new StatsigClient('client-key', { userID: 'a-user' }); + await client.initializeAsync(); + + fetchMock.mockImplementation(async (url, payload) => { + if (!url?.toString().includes('/rgstr')) { + return Promise.resolve(new Response()); + } + + const body = JSON.parse(String(payload?.body ?? '{}')); + events.push( + ...body.events.filter((e: StatsigEvent) => + e.eventName.includes('_exposure'), + ), + ); + return Promise.resolve(new Response()); + }); + + store = client.getParameterStore('a_param_store'); + }); + + describe('mapped gate params', () => { + let result: string; + + beforeAll(async () => { + events = []; + + result = store.get('a_string_param', 'fallback'); + await client.flush(); + }); + + it('gets the correct value', async () => { + expect(result).toEqual('Yea'); + }); + + it('logs a gate exposure event', () => { + expect(events[0].eventName).toEqual('statsig::gate_exposure'); + expect(events[0].metadata?.['gate']).toEqual(_DJB2('partial_gate')); + expect(events[0].metadata?.['reason']).toEqual('Network:Recognized'); + }); + }); + + describe('mapped experiment params', () => { + let result: number; + + beforeAll(async () => { + events = []; + + result = store.get('a_num_param', -1); + await client.flush(); + }); + + it('gets the correct value', async () => { + expect(result).toBe(2); + }); + + it('logs a gate exposure event', () => { + expect(events[0].eventName).toEqual('statsig::config_exposure'); + expect(events[0].metadata?.['config']).toEqual(_DJB2('three_groups')); + expect(events[0].metadata?.['reason']).toEqual('Network:Recognized'); + }); + }); + + describe('mapped layer params', () => { + let result: object; + + beforeAll(async () => { + events = []; + + result = store.get('an_object_param', { key: 'fallback' }); + await client.flush(); + }); + + it('gets the correct value', async () => { + expect(result).toEqual({}); + }); + + it('logs a gate exposure event', () => { + expect(events[0].eventName).toEqual('statsig::layer_exposure'); + expect(events[0].metadata?.['config']).toEqual(_DJB2('a_layer')); + expect(events[0].metadata?.['reason']).toEqual('Network:Recognized'); + }); + }); +}); diff --git a/packages/combo/src/__tests__/StatsigClientLocalOverrides.test.ts b/packages/combo/src/__tests__/StatsigClientLocalOverrides.test.ts index 6ab31b40..98a7551c 100644 --- a/packages/combo/src/__tests__/StatsigClientLocalOverrides.test.ts +++ b/packages/combo/src/__tests__/StatsigClientLocalOverrides.test.ts @@ -54,13 +54,13 @@ describe('Local Overrides - StatsigClient', () => { }); it('has the eval reason to "LocalOverride"', () => { - expect(gate.details.reason).toBe('LocalOverride'); + expect(gate.details.reason).toBe('LocalOverride:Recognized'); }); it('emits the correct client event', () => { const emission = emissions[0] as any; expect(emission.name).toBe('gate_evaluation'); - expect(emission.gate.details.reason).toBe('LocalOverride'); + expect(emission.gate.details.reason).toBe('LocalOverride:Recognized'); expect(emission.gate.value).toBe(true); }); @@ -71,7 +71,7 @@ describe('Local Overrides - StatsigClient', () => { const body = JSON.parse(String(payload?.body)) as any; const event = body.events[0]; expect(event.metadata.gate).toBe('a_gate'); - expect(event.metadata.reason).toBe('LocalOverride'); + expect(event.metadata.reason).toBe('LocalOverride:Recognized'); }); }); @@ -93,13 +93,15 @@ describe('Local Overrides - StatsigClient', () => { }); it('has the eval reason to "LocalOverride"', () => { - expect(config.details.reason).toBe('LocalOverride'); + expect(config.details.reason).toBe('LocalOverride:Recognized'); }); it('emits the correct client event', () => { const emission = emissions[0] as any; expect(emission.name).toBe('dynamic_config_evaluation'); - expect(emission.dynamicConfig.details.reason).toBe('LocalOverride'); + expect(emission.dynamicConfig.details.reason).toBe( + 'LocalOverride:Recognized', + ); expect(emission.dynamicConfig.value).toEqual({ a_string: 'foo' }); }); @@ -110,7 +112,7 @@ describe('Local Overrides - StatsigClient', () => { const body = JSON.parse(String(payload?.body)) as any; const event = body.events[0]; expect(event.metadata.config).toBe('a_config'); - expect(event.metadata.reason).toBe('LocalOverride'); + expect(event.metadata.reason).toBe('LocalOverride:Recognized'); }); }); @@ -134,13 +136,13 @@ describe('Local Overrides - StatsigClient', () => { }); it('has the eval reason to "LocalOverride"', () => { - expect(layer.details.reason).toBe('LocalOverride'); + expect(layer.details.reason).toBe('LocalOverride:Recognized'); }); it('emits the correct client event', () => { const emission = emissions[0] as any; expect(emission.name).toBe('layer_evaluation'); - expect(emission.layer.details.reason).toBe('LocalOverride'); + expect(emission.layer.details.reason).toBe('LocalOverride:Recognized'); expect(emission.layer.__value).toEqual({ a_string: 'foo' }); }); @@ -151,7 +153,7 @@ describe('Local Overrides - StatsigClient', () => { const body = JSON.parse(String(payload?.body)) as any; const event = body.events[0]; expect(event.metadata.config).toBe('a_layer'); - expect(event.metadata.reason).toBe('LocalOverride'); + expect(event.metadata.reason).toBe('LocalOverride:Recognized'); }); }); }); diff --git a/packages/js-local-overrides/src/LocalOverrideAdapter.ts b/packages/js-local-overrides/src/LocalOverrideAdapter.ts index d39e3e49..90e6d2c7 100644 --- a/packages/js-local-overrides/src/LocalOverrideAdapter.ts +++ b/packages/js-local-overrides/src/LocalOverrideAdapter.ts @@ -5,9 +5,11 @@ import { Layer, OverrideAdapter, StatsigUser, + _DJB2, + _makeTypedGet, } from '@statsig/client-core'; -const LOCAL_OVERRIDE_REASON = 'LocalOverride'; +const LOCAL_OVERRIDE_REASON = 'LocalOverride:Recognized'; export type OverrideStore = { gate: Record; @@ -30,17 +32,21 @@ export class LocalOverrideAdapter implements OverrideAdapter { overrideGate(name: string, value: boolean): void { this._overrides.gate[name] = value; + this._overrides.gate[_DJB2(name)] = value; } removeGateOverride(name: string): void { delete this._overrides.gate[name]; + delete this._overrides.gate[_DJB2(name)]; } getGateOverride( current: FeatureGate, _user: StatsigUser, ): FeatureGate | null { - const overridden = this._overrides.gate[current.name]; + const overridden = + this._overrides.gate[current.name] ?? + this._overrides.gate[_DJB2(current.name)]; if (overridden == null) { return null; } @@ -54,10 +60,12 @@ export class LocalOverrideAdapter implements OverrideAdapter { overrideDynamicConfig(name: string, value: Record): void { this._overrides.dynamicConfig[name] = value; + this._overrides.dynamicConfig[_DJB2(name)] = value; } removeDynamicConfigOverride(name: string): void { delete this._overrides.dynamicConfig[name]; + delete this._overrides.dynamicConfig[_DJB2(name)]; } getDynamicConfigOverride( @@ -69,10 +77,12 @@ export class LocalOverrideAdapter implements OverrideAdapter { overrideExperiment(name: string, value: Record): void { this._overrides.experiment[name] = value; + this._overrides.experiment[_DJB2(name)] = value; } removeExperimentOverride(name: string): void { delete this._overrides.experiment[name]; + delete this._overrides.experiment[_DJB2(name)]; } getExperimentOverride( @@ -84,10 +94,12 @@ export class LocalOverrideAdapter implements OverrideAdapter { overrideLayer(name: string, value: Record): void { this._overrides.layer[name] = value; + this._overrides.layer[_DJB2(name)] = value; } removeLayerOverride(name: string): void { delete this._overrides.layer[name]; + delete this._overrides.layer[_DJB2(name)]; } getAllOverrides(): OverrideStore { @@ -99,7 +111,9 @@ export class LocalOverrideAdapter implements OverrideAdapter { } getLayerOverride(current: Layer, _user: StatsigUser): Layer | null { - const overridden = this._overrides.layer[current.name]; + const overridden = + this._overrides.layer[current.name] ?? + this._overrides.layer[_DJB2(current.name)]; if (overridden == null) { return null; } @@ -107,6 +121,7 @@ export class LocalOverrideAdapter implements OverrideAdapter { return { ...current, __value: overridden, + get: _makeTypedGet(overridden), details: { ...current.details, reason: LOCAL_OVERRIDE_REASON }, }; } @@ -115,7 +130,7 @@ export class LocalOverrideAdapter implements OverrideAdapter { current: T, lookup: Record>, ): T | null { - const overridden = lookup[current.name]; + const overridden = lookup[current.name] ?? lookup[_DJB2(current.name)]; if (overridden == null) { return null; } @@ -123,6 +138,7 @@ export class LocalOverrideAdapter implements OverrideAdapter { return { ...current, value: overridden, + get: _makeTypedGet(overridden), details: { ...current.details, reason: LOCAL_OVERRIDE_REASON }, }; } diff --git a/packages/js-local-overrides/src/__tests__/LocalOverrides.test.ts b/packages/js-local-overrides/src/__tests__/LocalOverrides.test.ts index d868883d..80ca674d 100644 --- a/packages/js-local-overrides/src/__tests__/LocalOverrides.test.ts +++ b/packages/js-local-overrides/src/__tests__/LocalOverrides.test.ts @@ -1,4 +1,5 @@ import { + _DJB2, _makeDynamicConfig, _makeExperiment, _makeFeatureGate, @@ -30,18 +31,47 @@ describe('Local Overrides', () => { provider.overrideDynamicConfig(dynamicConfig.name, { dc: 'value' }); const overridden = provider.getDynamicConfigOverride(dynamicConfig, user); expect(overridden?.value).toEqual({ dc: 'value' }); + expect(overridden?.get('dc')).toEqual('value'); + expect(overridden?.details.reason).toBe('LocalOverride:Recognized'); }); it('returns overidden experiment', () => { provider.overrideExperiment(experiment.name, { exp: 'value' }); const overridden = provider.getExperimentOverride(experiment, user); expect(overridden?.value).toEqual({ exp: 'value' }); + expect(overridden?.get('exp')).toEqual('value'); + expect(overridden?.details.reason).toBe('LocalOverride:Recognized'); }); it('returns overidden layer', () => { provider.overrideLayer(layer.name, { layer_key: 'value' }); const overridden = provider.getLayerOverride(layer, user); expect(overridden?.__value).toEqual({ layer_key: 'value' }); + expect(overridden?.get('layer_key')).toEqual('value'); + expect(overridden?.details.reason).toBe('LocalOverride:Recognized'); + }); + + it('returns null for unrecognized overrides', () => { + provider.removeAllOverrides(); + + const overriddenGate = provider.getGateOverride(gate, user); + + const overriddenDynamicConfig = provider.getDynamicConfigOverride( + dynamicConfig, + user, + ); + + const overriddenExperiment = provider.getExperimentOverride( + experiment, + user, + ); + + const overriddenLayer = provider.getLayerOverride(layer, user); + + expect(overriddenGate).toBeNull(); + expect(overriddenDynamicConfig).toBeNull(); + expect(overriddenExperiment).toBeNull(); + expect(overriddenLayer).toBeNull(); }); it('returns all overrides', () => { @@ -51,10 +81,21 @@ describe('Local Overrides', () => { provider.overrideLayer(layer.name, { layer_key: 'value' }); expect(provider.getAllOverrides()).toEqual({ - dynamicConfig: { a_config: { dc: 'value' } }, - experiment: { an_experiment: { exp: 'value' } }, - gate: { a_gate: true }, - layer: { a_layer: { layer_key: 'value' } }, + dynamicConfig: { + a_config: { dc: 'value' }, + '2902556896': { + dc: 'value', + }, + }, + experiment: { + an_experiment: { exp: 'value' }, + '3921852239': { exp: 'value' }, + }, + gate: { a_gate: true, '2867927529': true }, + layer: { + a_layer: { layer_key: 'value' }, + '3011030003': { layer_key: 'value' }, + }, }); }); @@ -80,7 +121,10 @@ describe('Local Overrides', () => { provider.removeGateOverride('gate_a'); - expect(provider.getAllOverrides().gate).toEqual({ gate_b: true }); + expect(provider.getAllOverrides().gate).toEqual({ + gate_b: true, + [_DJB2('gate_b')]: true, + }); }); it('removes single dynamic config overrides', () => { @@ -91,6 +135,7 @@ describe('Local Overrides', () => { expect(provider.getAllOverrides().dynamicConfig).toEqual({ config_b: { b: 2 }, + [_DJB2('config_b')]: { b: 2 }, }); }); @@ -102,6 +147,7 @@ describe('Local Overrides', () => { expect(provider.getAllOverrides().experiment).toEqual({ experiment_b: { b: 2 }, + [_DJB2('experiment_b')]: { b: 2 }, }); }); @@ -113,6 +159,7 @@ describe('Local Overrides', () => { expect(provider.getAllOverrides().layer).toEqual({ layer_b: { b: 2 }, + [_DJB2('layer_b')]: { b: 2 }, }); }); }); diff --git a/samples/next-js/src/app/param-store-example/ParamStoreExample.tsx b/samples/next-js/src/app/param-store-example/ParamStoreExample.tsx index a0864936..6c38de8d 100644 --- a/samples/next-js/src/app/param-store-example/ParamStoreExample.tsx +++ b/samples/next-js/src/app/param-store-example/ParamStoreExample.tsx @@ -1,12 +1,11 @@ 'use client'; import { useEffect } from 'react'; -import { StatsigUser } from 'statsig-node'; import { AnyStatsigClientEvent } from '@statsig/client-core'; import { StatsigProvider, - useClientBootstrapInit, + useClientAsyncInit, useParameterStore, } from '@statsig/react-bindings'; @@ -24,31 +23,31 @@ function ResultRow({ title, result }: { title: string; result: string }) { } function Content() { - const store = useParameterStore('my_param_store'); - const noExposureStore = useParameterStore('my_param_store', { + const store = useParameterStore('a_param_store'); + const noExposureStore = useParameterStore('a_param_store', { disableExposureLog: true, }); return (
{ const onAnyClientEvent = (event: AnyStatsigClientEvent) => @@ -77,6 +72,10 @@ export default function ParamStoreExample({ return () => client.off('*', onAnyClientEvent); }, [client]); + if (isLoading) { + return
Statsig Loading...
; + } + return ( diff --git a/samples/next-js/src/app/param-store-example/page.tsx b/samples/next-js/src/app/param-store-example/page.tsx index 69ffefa2..03a24117 100644 --- a/samples/next-js/src/app/param-store-example/page.tsx +++ b/samples/next-js/src/app/param-store-example/page.tsx @@ -1,46 +1,5 @@ -import { getStatsigValues } from '../../utils/statsig-server'; import ParamStoreExample from './ParamStoreExample'; export default async function Index(): Promise { - const user = { userID: 'a-user' }; - let values = await getStatsigValues(user); - - // until the server side is complete - const json = JSON.parse(values) as Record; - json['param_stores'] = { - my_param_store: { - my_static_value_string: { - ref_type: 'static', - param_type: 'string', - value: 'hello', - }, - - my_gated_value_string: { - ref_type: 'gate', - param_type: 'string', - gate_name: 'a_gate', - pass_value: 'Gate Passed', - fail_value: 'Gate Failed', - }, - - my_gated_value_boolean: { - ref_type: 'gate', - param_type: 'boolean', - gate_name: 'a_gate', - pass_value: true, - fail_value: false, - }, - - my_failing_gated_value_boolean: { - ref_type: 'gate', - param_type: 'boolean', - gate_name: 'third_gate', - pass_value: true, - fail_value: false, - }, - }, - }; - values = JSON.stringify(json); - - return ; + return ; }