diff --git a/packages/client-core/src/DataAdapterCore.ts b/packages/client-core/src/DataAdapterCore.ts index 1d72a2b3..879486a8 100644 --- a/packages/client-core/src/DataAdapterCore.ts +++ b/packages/client-core/src/DataAdapterCore.ts @@ -6,7 +6,12 @@ import { DataSource, } from './StatsigDataAdapter'; import { AnyStatsigOptions } from './StatsigOptionsCommon'; -import { StatsigUser, _getFullUserHash, _normalizeUser } from './StatsigUser'; +import { + StatsigUser, + StatsigUserInternal, + _getFullUserHash, + _normalizeUser, +} from './StatsigUser'; import { Storage, _getObjectFromStorage, @@ -37,8 +42,9 @@ export abstract class DataAdapterCore { } getDataSync(user?: StatsigUser | undefined): DataAdapterResult | null { - const cacheKey = this._getCacheKey(user); - const inMem = this._inMemoryCache.get(cacheKey, user); + const normalized = user && _normalizeUser(user, this._options); + const cacheKey = this._getCacheKey(normalized); + const inMem = this._inMemoryCache.get(cacheKey, normalized); if (inMem) { return inMem; @@ -47,14 +53,14 @@ export abstract class DataAdapterCore { const cache = this._loadFromCache(cacheKey); if (cache) { this._inMemoryCache.add(cacheKey, cache); - return this._inMemoryCache.get(cacheKey, user); + return this._inMemoryCache.get(cacheKey, normalized); } return null; } setData(data: string, user?: StatsigUser): void { - const normalized = user && _normalizeUser(user, this._options?.environment); + const normalized = user && _normalizeUser(user, this._options); const cacheKey = this._getCacheKey(normalized); this._inMemoryCache.add( @@ -74,7 +80,7 @@ export abstract class DataAdapterCore { protected async _getDataAsyncImpl( current: DataAdapterResult | null, - user?: StatsigUser, + user?: StatsigUserInternal, options?: DataAdapterAsyncOptions, ): Promise { const cache = current ?? this.getDataSync(user); @@ -97,8 +103,9 @@ export abstract class DataAdapterCore { user?: StatsigUser, options?: DataAdapterAsyncOptions, ): Promise { - const cacheKey = this._getCacheKey(user); - const result = await this._getDataAsyncImpl(null, user, options); + const normalized = user && _normalizeUser(user, this._options); + const cacheKey = this._getCacheKey(normalized); + const result = await this._getDataAsyncImpl(null, normalized, options); if (result) { this._inMemoryCache.add(cacheKey, { ...result, source: 'Prefetch' }); } @@ -110,7 +117,7 @@ export abstract class DataAdapterCore { options?: DataAdapterAsyncOptions, ): Promise; - protected abstract _getCacheKey(user?: StatsigUser): string; + protected abstract _getCacheKey(user?: StatsigUserInternal): string; protected abstract _isCachedResultValidFor204( result: DataAdapterResult, @@ -119,7 +126,7 @@ export abstract class DataAdapterCore { private async _fetchAndPrepFromNetwork( cachedResult: DataAdapterResult | null, - user: StatsigUser | undefined, + user: StatsigUserInternal | undefined, options: DataAdapterAsyncOptions | undefined, ): Promise { let cachedData: string | null = null; @@ -232,7 +239,7 @@ class InMemoryCache { get( cacheKey: string, - user: StatsigUser | undefined, + user: StatsigUserInternal | undefined, ): DataAdapterResult | null { const result = this._data[cacheKey]; const cached = result?.stableID; diff --git a/packages/client-core/src/EventLogger.ts b/packages/client-core/src/EventLogger.ts index af156e26..135f7618 100644 --- a/packages/client-core/src/EventLogger.ts +++ b/packages/client-core/src/EventLogger.ts @@ -157,7 +157,7 @@ export class EventLogger { return true; } - const user = event.user ? event.user : {}; + const user = event.user ? event.user : { statsigEnvironment: undefined }; const metadata = event.metadata ? event.metadata : {}; const key = [ event.eventName, diff --git a/packages/client-core/src/StatsigEvent.ts b/packages/client-core/src/StatsigEvent.ts index ee7036af..8f0fc764 100644 --- a/packages/client-core/src/StatsigEvent.ts +++ b/packages/client-core/src/StatsigEvent.ts @@ -1,6 +1,6 @@ import { EvaluationDetails, SecondaryExposure } from './EvaluationTypes'; import { DynamicConfig, FeatureGate, Layer } from './StatsigTypes'; -import { StatsigUser } from './StatsigUser'; +import { StatsigUserInternal } from './StatsigUser'; export type StatsigEvent = { eventName: string; @@ -9,7 +9,7 @@ export type StatsigEvent = { }; export type StatsigEventInternal = Omit & { - user: StatsigUser | null; + user: StatsigUserInternal | null; time: number; metadata?: { [key: string]: unknown } | null; secondaryExposures?: SecondaryExposure[]; @@ -21,7 +21,7 @@ const LAYER_EXPOSURE_NAME = 'statsig::layer_exposure'; const _createExposure = ( eventName: string, - user: StatsigUser, + user: StatsigUserInternal, details: EvaluationDetails, metadata: Record, secondaryExposures: SecondaryExposure[], @@ -43,7 +43,7 @@ export const _isExposureEvent = ({ }; export const _createGateExposure = ( - user: StatsigUser, + user: StatsigUserInternal, gate: FeatureGate, ): StatsigEventInternal => { return _createExposure( @@ -60,7 +60,7 @@ export const _createGateExposure = ( }; export const _createConfigExposure = ( - user: StatsigUser, + user: StatsigUserInternal, config: DynamicConfig, ): StatsigEventInternal => { return _createExposure( @@ -76,7 +76,7 @@ export const _createConfigExposure = ( }; export const _createLayerParameterExposure = ( - user: StatsigUser, + user: StatsigUserInternal, layer: Layer, parameterName: string, ): StatsigEventInternal => { diff --git a/packages/client-core/src/StatsigUser.ts b/packages/client-core/src/StatsigUser.ts index cef5b742..fafd00e2 100644 --- a/packages/client-core/src/StatsigUser.ts +++ b/packages/client-core/src/StatsigUser.ts @@ -1,6 +1,9 @@ import { _DJB2Object } from './Hashing'; import { Log } from './Log'; -import type { StatsigEnvironment } from './StatsigOptionsCommon'; +import type { + AnyStatsigOptions, + StatsigEnvironment, +} from './StatsigOptionsCommon'; type StatsigUserPrimitives = | string @@ -26,24 +29,24 @@ export type StatsigUser = { }; export type StatsigUserInternal = StatsigUser & { - statsigEnvironment?: StatsigEnvironment; + statsigEnvironment: StatsigEnvironment | undefined; }; export function _normalizeUser( original: StatsigUser, - environment?: StatsigEnvironment, -): StatsigUser { + options?: AnyStatsigOptions | null, +): StatsigUserInternal { try { const copy = JSON.parse(JSON.stringify(original)) as StatsigUserInternal; - if (environment != null) { - copy.statsigEnvironment = environment; + if (options != null && options.environment != null) { + copy.statsigEnvironment = options.environment; } return copy; } catch (error) { Log.error('Failed to JSON.stringify user'); - return {}; + return { statsigEnvironment: undefined }; } } diff --git a/packages/js-client/src/StatsigClient.ts b/packages/js-client/src/StatsigClient.ts index 64c20db7..ccb81f5f 100644 --- a/packages/js-client/src/StatsigClient.ts +++ b/packages/js-client/src/StatsigClient.ts @@ -22,6 +22,7 @@ import { StatsigEvent, StatsigSession, StatsigUser, + StatsigUserInternal, _createConfigExposure, _createGateExposure, _createLayerParameterExposure, @@ -48,7 +49,7 @@ export default class StatsigClient implements PrecomputedEvaluationsInterface { private _store: EvaluationStore; - private _user: StatsigUser; + private _user: StatsigUserInternal; /** * Retrieves an instance of the StatsigClient based on the provided SDK key. @@ -97,7 +98,7 @@ export default class StatsigClient ); this._store = new EvaluationStore(); - this._user = user; + this._user = _normalizeUser(user, options); } /** @@ -411,7 +412,7 @@ export default class StatsigClient this._logger.reset(); this._store.reset(); - this._user = _normalizeUser(user, this._options.environment); + this._user = _normalizeUser(user, this._options); const stableIdOverride = this._user.customIDs?.stableID; if (stableIdOverride) { diff --git a/packages/js-client/src/StatsigEvaluationsDataAdapter.ts b/packages/js-client/src/StatsigEvaluationsDataAdapter.ts index f7f5f37e..939309b4 100644 --- a/packages/js-client/src/StatsigEvaluationsDataAdapter.ts +++ b/packages/js-client/src/StatsigEvaluationsDataAdapter.ts @@ -7,8 +7,10 @@ import { InitializeResponse, Log, StatsigUser, + StatsigUserInternal, _getFullUserHash, _getStorageKey, + _normalizeUser, _typedJsonParse, } from '@statsig/client-core'; @@ -36,7 +38,11 @@ export class StatsigEvaluationsDataAdapter user: StatsigUser, options?: DataAdapterAsyncOptions, ): Promise { - return this._getDataAsyncImpl(current, user, options); + return this._getDataAsyncImpl( + current, + _normalizeUser(user, this._options), + options, + ); } prefetchData( @@ -78,7 +84,7 @@ export class StatsigEvaluationsDataAdapter return result ?? null; } - protected override _getCacheKey(user?: StatsigUser): string { + protected override _getCacheKey(user?: StatsigUserInternal): string { const key = _getStorageKey( this._getSdkKey(), user, diff --git a/packages/js-client/src/__tests__/ClientAndEvironments.test.ts b/packages/js-client/src/__tests__/ClientAndEvironments.test.ts new file mode 100644 index 00000000..dfa9dbbe --- /dev/null +++ b/packages/js-client/src/__tests__/ClientAndEvironments.test.ts @@ -0,0 +1,92 @@ +import fetchMock from 'jest-fetch-mock'; +import { InitResponseString, MockLocalStorage } from 'statsig-test-helpers'; + +import StatsigClient from '../StatsigClient'; + +describe('StatsigClient and Environments', () => { + const user = { userID: 'a-user' }; + const env = { statsigEnvironment: { tier: 'dev' } }; + const expectedCacheKey = 'statsig.cached.evaluations.1769418430'; // DJB2(JSON({userID: 'a-user', statsigEnvironment: {tier: 'dev'}})) + + let storageMock: MockLocalStorage; + let client: StatsigClient; + + beforeAll(() => { + storageMock = MockLocalStorage.enabledMockStorage(); + fetchMock.enableMocks(); + }); + + afterAll(() => { + jest.clearAllMocks(); + MockLocalStorage.disableMockStorage(); + }); + + beforeEach(() => { + storageMock.clear(); + + fetchMock.mock.calls = []; + fetchMock.mockResponse(InitResponseString); + + client = new StatsigClient('client-key', user, { + environment: { tier: 'dev' }, + disableStatsigEncoding: true, + }); + }); + + describe('When triggered by StatsigClient', () => { + it('sets the environment on post sync init requests', async () => { + client.initializeSync(); + await new Promise((r) => setTimeout(r, 1)); + + const [, req] = fetchMock.mock.calls[0]; + const body = JSON.parse(String(req?.body)); + + expect(body.user).toMatchObject(env); + expect(storageMock.data[expectedCacheKey]).toBeDefined(); + }); + + it('sets the environment on async init requests', async () => { + await client.initializeAsync(); + + const [, req] = fetchMock.mock.calls[0]; + const body = JSON.parse(String(req?.body)); + + expect(body.user).toMatchObject(env); + expect(storageMock.data[expectedCacheKey]).toBeDefined(); + }); + }); + + describe('When triggered by DataAdapter', () => { + it('sets the environment on prefetch requests', async () => { + await client.dataAdapter.prefetchData(user); + + const [, req] = fetchMock.mock.calls[0]; + const body = JSON.parse(String(req?.body)); + + expect(body.user).toMatchObject(env); + expect(storageMock.data[expectedCacheKey]).toBeDefined(); + }); + + it('sets the environment on getDataAsync requests', async () => { + await client.dataAdapter.getDataAsync(null, user, {}); + + const [, req] = fetchMock.mock.calls[0]; + const body = JSON.parse(String(req?.body)); + + expect(body.user).toMatchObject(env); + expect(storageMock.data[expectedCacheKey]).toBeDefined(); + }); + }); + + it('includes env on DataAdapter reads', () => { + storageMock.data[expectedCacheKey] = JSON.stringify({ + source: 'Network', + receivedAt: Date.now(), + data: InitResponseString, + stableID: null, + fullUserHash: null, + }); + + expect(client.dataAdapter.getDataSync(user)).toBeDefined(); + }); +}); diff --git a/packages/js-on-device-eval-client/src/StatsigOnDeviceEvalClient.ts b/packages/js-on-device-eval-client/src/StatsigOnDeviceEvalClient.ts index 596eaee1..7b4ecc75 100644 --- a/packages/js-on-device-eval-client/src/StatsigOnDeviceEvalClient.ts +++ b/packages/js-on-device-eval-client/src/StatsigOnDeviceEvalClient.ts @@ -148,12 +148,15 @@ export default class StatsigOnDeviceEvalClient user: StatsigUser, options?: FeatureGateEvaluationOptions, ): FeatureGate { - user = _normalizeUser(user, this._options.environment); - const { evaluation, details } = this._evaluator.evaluateGate(name, user); + const normalized = _normalizeUser(user, this._options); + const { evaluation, details } = this._evaluator.evaluateGate( + name, + normalized, + ); const gate = _makeFeatureGate(name, details, evaluation); - this._enqueueExposure(name, _createGateExposure(user, gate), options); + this._enqueueExposure(name, _createGateExposure(normalized, gate), options); this.$emt({ name: 'gate_evaluation', gate }); @@ -165,18 +168,25 @@ export default class StatsigOnDeviceEvalClient user: StatsigUser, options?: DynamicConfigEvaluationOptions, ): DynamicConfig { - user = _normalizeUser(user, this._options.environment); - const { evaluation, details } = this._evaluator.evaluateConfig(name, user); + const normalized = _normalizeUser(user, this._options); + const { evaluation, details } = this._evaluator.evaluateConfig( + name, + normalized, + ); const config = _makeDynamicConfig(name, details, evaluation); const overridden = this._overrideAdapter?.getDynamicConfigOverride?.( config, - user, + normalized, options, ); const result = overridden ?? config; - this._enqueueExposure(name, _createConfigExposure(user, result), options); + this._enqueueExposure( + name, + _createConfigExposure(normalized, result), + options, + ); this.$emt({ name: 'dynamic_config_evaluation', dynamicConfig: result }); return result; } @@ -186,18 +196,25 @@ export default class StatsigOnDeviceEvalClient user: StatsigUser, options?: ExperimentEvaluationOptions, ): Experiment { - user = _normalizeUser(user, this._options.environment); - const { evaluation, details } = this._evaluator.evaluateConfig(name, user); + const normalized = _normalizeUser(user, this._options); + const { evaluation, details } = this._evaluator.evaluateConfig( + name, + normalized, + ); const experiment = _makeExperiment(name, details, evaluation); const overridden = this._overrideAdapter?.getExperimentOverride?.( experiment, - user, + normalized, options, ); const result = overridden ?? experiment; - this._enqueueExposure(name, _createConfigExposure(user, result), options); + this._enqueueExposure( + name, + _createConfigExposure(normalized, result), + options, + ); this.$emt({ name: 'experiment_evaluation', experiment: result }); return result; } @@ -207,13 +224,16 @@ export default class StatsigOnDeviceEvalClient user: StatsigUser, options?: LayerEvaluationOptions, ): Layer { - user = _normalizeUser(user, this._options.environment); - const { evaluation, details } = this._evaluator.evaluateLayer(name, user); + const normalized = _normalizeUser(user, this._options); + const { evaluation, details } = this._evaluator.evaluateLayer( + name, + normalized, + ); const layer = _makeLayer(name, details, evaluation, (param: string) => { this._enqueueExposure( name, - _createLayerParameterExposure(user, layer, param), + _createLayerParameterExposure(normalized, layer, param), options, ); }); @@ -238,7 +258,11 @@ export default class StatsigOnDeviceEvalClient } : eventOrName; - this._logger.enqueue({ ...event, user, time: Date.now() }); + this._logger.enqueue({ + ...event, + user: _normalizeUser(user, this._options), + time: Date.now(), + }); } protected override _primeReadyRipcord(): void {