Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 165 additions & 3 deletions __tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
for (let i = 0; i < 10; i++) {
client.variant(serverKey);
}
const variant = client.variant(serverKey);

Check warning on line 269 in __tests__/client.test.ts

View workflow job for this annotation

GitHub Actions / lint

'variant' is assigned a value but never used

expect(trackSpy).toBeCalledTimes(1);
expect(trackSpy).toHaveBeenCalledWith(
Expand Down Expand Up @@ -295,13 +296,14 @@
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));
});
Expand Down Expand Up @@ -694,7 +696,7 @@
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();
Expand All @@ -717,7 +719,7 @@
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();
Expand Down Expand Up @@ -1053,3 +1055,163 @@
},
);
});

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),
}),
);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
24 changes: 24 additions & 0 deletions src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ import {
Poller,
SdkFlagApi,
SdkEvaluationApi,
GetVariantsOptions,
} from '@amplitude/experiment-core';

import { version as PACKAGE_VERSION } from './gen/version';
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';
Expand Down Expand Up @@ -81,6 +84,7 @@ export class ExperimentClient implements Client {
private isRunning = false;
private readonly flagsAndVariantsLoadedPromise: Promise<void>[] | undefined;
private readonly initialFlags: EvaluationFlag[] | undefined;
private readonly fetchVariantsOptions: SingleValueStoreCache<GetVariantsOptions>;

/**
* Creates a new ExperimentClient instance.
Expand Down Expand Up @@ -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(),
];
}

Expand Down Expand Up @@ -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<void> {
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<string, EvaluationFlag> {
if (this.initialFlags) {
const flagsMap: Record<string, EvaluationFlag> = {};
Expand Down Expand Up @@ -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,
});
Expand Down
49 changes: 48 additions & 1 deletion src/storage/cache.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,6 +29,53 @@ export const getFlagStorage = (
return new LoadStoreCache<EvaluationFlag>(namespace, storage);
};

export const getVariantsOptionsStorage = (
deploymentKey: string,
instanceName: string,
storage: Storage = new LocalStorage(),
): SingleValueStoreCache<GetVariantsOptions> => {
const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6);
const namespace = `amp-exp-${instanceName}-${truncatedDeployment}-variants-options`;
return new SingleValueStoreCache<GetVariantsOptions>(namespace, storage);
};

export class SingleValueStoreCache<V> {
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<void> {
const value = await this.storage.get(this.namespace);
if (value) {
this.value = JSON.parse(value);
}
}

public async store(): Promise<void> {
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<V> {
private readonly namespace: string;
private readonly storage: Storage;
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down