diff --git a/__tests__/client.test.ts b/__tests__/client.test.ts index 17dbcac..5f4cdda 100644 --- a/__tests__/client.test.ts +++ b/__tests__/client.test.ts @@ -266,6 +266,7 @@ test('ExperimentClient.variant, with exposure tracking provider, track called on for (let i = 0; i < 10; i++) { client.variant(serverKey); } + const variant = client.variant(serverKey); expect(trackSpy).toBeCalledTimes(1); expect(trackSpy).toHaveBeenCalledWith( @@ -295,13 +296,14 @@ test('ExperimentClient.variant, with analytics provider, exposure tracked, unset exposureTrackingProvider: exposureTrackingProvider, }); await client.fetch(testUser); - client.variant(serverKey); + const variant = client.variant(serverKey); expect(spyTrack).toBeCalledTimes(1); const expectedEvent = { flag_key: serverKey, variant: serverVariant.value, + metadata: variant.metadata, }; expect(spyTrack).lastCalledWith(expect.objectContaining(expectedEvent)); }); @@ -694,7 +696,7 @@ describe('variant fallbacks', () => { expect(variantString).toEqual({ key: 'inline', value: 'inline' }); // Variant is result of inline fallback object const variantObject = client.variant('sdk-ci-test', { value: 'inline' }); - expect(variantObject).toEqual({ value: 'inline' }); + expect(variantObject).toMatchObject({ value: 'inline' }); expect(spy).toHaveBeenCalledTimes(1); expect((spy.mock.calls[0] as any[])[0].flag_key).toEqual('sdk-ci-test'); expect((spy.mock.calls[0] as any[])[0].variant).toBeUndefined(); @@ -717,7 +719,7 @@ describe('variant fallbacks', () => { await client.start(user); const variant = client.variant('sdk-ci-test'); // Variant is result of fallbackVariant - expect(variant).toEqual({ key: 'fallback', value: 'fallback' }); + expect(variant).toMatchObject({ key: 'fallback', value: 'fallback' }); expect(spy).toHaveBeenCalledTimes(1); expect((spy.mock.calls[0] as any[])[0].flag_key).toEqual('sdk-ci-test'); expect((spy.mock.calls[0] as any[])[0].variant).toBeUndefined(); @@ -1053,3 +1055,163 @@ describe('fetch retry with different response codes', () => { }, ); }); + +describe('setTracksAssignment', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + jest.restoreAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('setTracksAssignment(boolean) sets trackingOption to track and getVariants is called with correct options', async () => { + const client = new ExperimentClient(API_KEY, {}); + + // Mock the evaluationApi.getVariants method + const getVariantsSpy = jest.spyOn( + (client as any).evaluationApi, + 'getVariants', + ); + getVariantsSpy.mockResolvedValue({ + 'test-flag': { key: 'on', value: 'on' }, + }); + + // Set track assignment event to true + await client.setTracksAssignment(true); + + // Fetch variants to trigger the API call + await client.fetch(testUser); + + // Verify getVariants was called with trackingOption: 'track' + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: testUser.user_id, + library: expect.stringContaining('experiment-react-native-client'), + }), + expect.objectContaining({ + trackingOption: 'track', + timeoutMillis: expect.any(Number), + }), + ); + + // Set track assignment event to false + await client.setTracksAssignment(false); + + // Fetch variants to trigger the API call + await client.fetch(testUser); + + // Verify getVariants was called with trackingOption: 'no-track' + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: testUser.user_id, + library: expect.stringContaining('experiment-react-native-client'), + }), + expect.objectContaining({ + trackingOption: 'no-track', + timeoutMillis: expect.any(Number), + }), + ); + }); + + test('setTracksAssignment persists the setting to storage', async () => { + const client = new ExperimentClient(API_KEY, {}); + + // Set track assignment event to true + await client.setTracksAssignment(true); + + // Create a new client instance to verify persistence + const client2 = new ExperimentClient(API_KEY, {}); + await client2.cacheReady(); + + // Mock the evaluationApi.getVariants method for the second client + const getVariantsSpy = jest.spyOn( + (client2 as any).evaluationApi, + 'getVariants', + ); + getVariantsSpy.mockResolvedValue({ + 'test-flag': { key: 'on', value: 'on' }, + }); + + // Fetch variants with the second client + await client2.fetch(testUser); + + // Verify the setting was persisted and loaded by the second client + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: testUser.user_id, + library: expect.stringContaining('experiment-react-native-client'), + }), + expect.objectContaining({ + trackingOption: 'track', + timeoutMillis: expect.any(Number), + }), + ); + }); + + test('multiple calls to setTracksAssignment uses the latest setting', async () => { + const client = new ExperimentClient(API_KEY, {}); + + // Mock the evaluationApi.getVariants method + const getVariantsSpy = jest.spyOn( + (client as any).evaluationApi, + 'getVariants', + ); + getVariantsSpy.mockResolvedValue({ + 'test-flag': { key: 'off', value: 'off' }, + }); + + // Set track assignment event to true, then false + await client.setTracksAssignment(true); + await client.setTracksAssignment(false); + + // Fetch variants to trigger the API call + await client.fetch(testUser); + + // Verify getVariants was called with the latest setting (no-track) + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: testUser.user_id, + library: expect.stringContaining('experiment-react-native-client'), + }), + expect.objectContaining({ + trackingOption: 'no-track', + timeoutMillis: expect.any(Number), + }), + ); + }); + + test('setTracksAssignment preserves other existing options while updating trackingOption', async () => { + const client = new ExperimentClient(API_KEY, {}); + + // Mock the evaluationApi.getVariants method + const getVariantsSpy = jest.spyOn( + (client as any).evaluationApi, + 'getVariants', + ); + getVariantsSpy.mockResolvedValue({ + 'test-flag': { key: 'on', value: 'on' }, + }); + + // Set track assignment event to true + await client.setTracksAssignment(true); + + // Fetch variants with specific flag keys to ensure other options are preserved + const fetchOptions = { flagKeys: ['test-flag'] }; + await client.fetch(testUser, fetchOptions); + + // Verify getVariants was called with both trackingOption and flagKeys + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: testUser.user_id, + library: expect.stringContaining('experiment-react-native-client'), + }), + expect.objectContaining({ + trackingOption: 'track', + flagKeys: ['test-flag'], + timeoutMillis: expect.any(Number), + }), + ); + }); +}); diff --git a/package.json b/package.json index f4f1c55..ef99906 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@amplitude/analytics-connector": "^1.4.7", - "@amplitude/experiment-core": "^0.7.2", + "@amplitude/experiment-core": "^0.11.0", "@react-native-async-storage/async-storage": "^1.17.6", "unfetch": "^4.2.0" }, diff --git a/src/experimentClient.ts b/src/experimentClient.ts index b2f12e5..39aad4a 100644 --- a/src/experimentClient.ts +++ b/src/experimentClient.ts @@ -13,6 +13,7 @@ import { Poller, SdkFlagApi, SdkEvaluationApi, + GetVariantsOptions, } from '@amplitude/experiment-core'; import { version as PACKAGE_VERSION } from './gen/version'; @@ -20,8 +21,10 @@ import { ConnectorUserProvider } from './integration/connector'; import { DefaultUserProvider } from './integration/default'; import { getFlagStorage, + getVariantsOptionsStorage, getVariantStorage, LoadStoreCache, + SingleValueStoreCache, } from './storage/cache'; import { LocalStorage } from './storage/local-storage'; import { FetchHttpClient, WrapperClient } from './transport/http'; @@ -81,6 +84,7 @@ export class ExperimentClient implements Client { private isRunning = false; private readonly flagsAndVariantsLoadedPromise: Promise[] | undefined; private readonly initialFlags: EvaluationFlag[] | undefined; + private readonly fetchVariantsOptions: SingleValueStoreCache; /** * Creates a new ExperimentClient instance. @@ -140,9 +144,15 @@ export class ExperimentClient implements Client { if (this.config.initialFlags) { this.initialFlags = JSON.parse(this.config.initialFlags); } + this.fetchVariantsOptions = getVariantsOptionsStorage( + this.apiKey, + this.config.instanceName, + storage, + ); this.flagsAndVariantsLoadedPromise = [ this.flags.load(this.convertInitialFlagsForStorage()), this.variants.load(), + this.fetchVariantsOptions.load(), ]; } @@ -385,6 +395,19 @@ export class ExperimentClient implements Client { return this; } + /** + * Enables or disables tracking of assignment events when fetching variants. + * @param doTrack Whether to track assignment events. + */ + public async setTracksAssignment(doTrack: boolean): Promise { + this.fetchVariantsOptions.put({ + ...this.fetchVariantsOptions.get(), + trackingOption: doTrack ? 'track' : 'no-track', + }); + // No need to wait for persistence to complete. + this.fetchVariantsOptions.store(); + } + private convertInitialFlagsForStorage(): Record { if (this.initialFlags) { const flagsMap: Record = {}; @@ -664,6 +687,7 @@ export class ExperimentClient implements Client { user = await this.addContextOrWait(user, 10000); this.debug('[Experiment] Fetch variants for user: ', user); const results = await this.evaluationApi.getVariants(user, { + ...this.fetchVariantsOptions.get(), timeoutMillis: timeoutMillis, flagKeys: options?.flagKeys, }); diff --git a/src/storage/cache.ts b/src/storage/cache.ts index 1b9960b..6c761ce 100644 --- a/src/storage/cache.ts +++ b/src/storage/cache.ts @@ -1,4 +1,4 @@ -import { EvaluationFlag } from '@amplitude/experiment-core'; +import { EvaluationFlag, GetVariantsOptions } from '@amplitude/experiment-core'; import { Storage } from '../types/storage'; import { Variant } from '../types/variant'; @@ -29,6 +29,53 @@ export const getFlagStorage = ( return new LoadStoreCache(namespace, storage); }; +export const getVariantsOptionsStorage = ( + deploymentKey: string, + instanceName: string, + storage: Storage = new LocalStorage(), +): SingleValueStoreCache => { + const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6); + const namespace = `amp-exp-${instanceName}-${truncatedDeployment}-variants-options`; + return new SingleValueStoreCache(namespace, storage); +}; + +export class SingleValueStoreCache { + private readonly namespace: string; + private readonly storage: Storage; + private value: V | undefined; + + constructor(namespace: string, storage: Storage) { + this.namespace = namespace; + this.storage = storage; + this.value = this.get(); + } + + public get(): V | undefined { + return this.value; + } + + public put(value: V): void { + this.value = value; + } + + public async load(): Promise { + const value = await this.storage.get(this.namespace); + if (value) { + this.value = JSON.parse(value); + } + } + + public async store(): Promise { + if (this.value === undefined) { + // Delete the key if the value is undefined + await this.storage.delete(this.namespace); + } else { + // Also store false or null values + await this.storage.put(this.namespace, JSON.stringify(this.value)); + } + } +} + export class LoadStoreCache { private readonly namespace: string; private readonly storage: Storage; diff --git a/yarn.lock b/yarn.lock index df54e43..0cee6bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,10 +14,10 @@ dependencies: "@amplitude/ua-parser-js" "^0.7.31" -"@amplitude/experiment-core@^0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz#f94219d68d86322e8d580c8fbe0672dcd29f86bb" - integrity sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA== +"@amplitude/experiment-core@^0.11.0": + version "0.11.1" + resolved "https://registry.yarnpkg.com/@amplitude/experiment-core/-/experiment-core-0.11.1.tgz#31eb485dc40b65f4cc2f15adab9b62f0df622065" + integrity sha512-fHVJazCwwgjUcj49N+yFSLMSroyY0eHImrsmd1ipIjIT1Cez8mh+TvwJQ0LGSdyYvL2NAZM2J5vpXrR1nHgdzg== dependencies: js-base64 "^3.7.5"