Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ dist/

# Example Experiment tag script
packages/experiment-tag/example/

# dotenv files
.env*
25 changes: 23 additions & 2 deletions packages/experiment-browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
TimeoutError,
topologicalSort,
getGlobalScope,
GetVariantsOptions,
} from '@amplitude/experiment-core';

import { version as PACKAGE_VERSION } from '../package.json';
Expand All @@ -23,8 +24,10 @@ import { Defaults, ExperimentConfig } from './config';
import { IntegrationManager } from './integration/manager';
import {
getFlagStorage,
getVariantsOptionsStorage,
getVariantStorage,
LoadStoreCache,
SingleValueStoreCache,
transformVariantFromStorage,
} from './storage/cache';
import { LocalStorage } from './storage/local-storage';
Expand Down Expand Up @@ -89,6 +92,7 @@ export class ExperimentClient implements Client {
private readonly integrationManager: IntegrationManager;
// Web experiment adds a user to the flags request
private readonly isWebExperiment: boolean;
private readonly fetchVariantsOptions: SingleValueStoreCache<GetVariantsOptions>;

// Deprecated
private analyticsProvider: SessionAnalyticsProvider | undefined;
Expand Down Expand Up @@ -186,9 +190,15 @@ export class ExperimentClient implements Client {
storage,
);
this.flags = getFlagStorage(this.apiKey, storageInstanceName, storage);
this.fetchVariantsOptions = getVariantsOptionsStorage(
this.apiKey,
storageInstanceName,
storage,
);
try {
this.flags.load();
this.variants.load();
this.fetchVariantsOptions.load();
} catch (e) {
// catch localStorage undefined error
}
Expand Down Expand Up @@ -706,7 +716,10 @@ export class ExperimentClient implements Client {
}

try {
const variants = await this.doFetch(user, timeoutMillis, options);
const variants = await this.doFetch(user, timeoutMillis, {
trackingOption: this.fetchVariantsOptions.get()?.trackingOption,
...options,
});
await this.storeVariants(variants, options);
return variants;
} catch (e) {
Expand All @@ -717,6 +730,14 @@ export class ExperimentClient implements Client {
}
}

public async setTrackAssignmentEvent(doTrack: boolean): Promise<void> {
this.fetchVariantsOptions.put({
...(this.fetchVariantsOptions.get() || {}),
trackingOption: doTrack ? 'track' : 'no-track',
});
this.fetchVariantsOptions.store();
}

private cleanUserPropsForFetch(user: ExperimentUser): ExperimentUser {
const cleanedUser = { ...user };
delete cleanedUser.cookie;
Expand All @@ -726,7 +747,7 @@ export class ExperimentClient implements Client {
private async doFetch(
user: ExperimentUser,
timeoutMillis: number,
options?: FetchOptions,
options?: GetVariantsOptions,
): Promise<Variants> {
user = await this.addContextOrWait(user);
user = this.cleanUserPropsForFetch(user);
Expand Down
48 changes: 47 additions & 1 deletion packages/experiment-browser/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,52 @@ 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;
}

public get(): V | undefined {
return this.value;
}

public put(value: V): void {
this.value = value;
}

public load(): void {
const value = this.storage.get(this.namespace);
if (value) {
this.value = JSON.parse(value);
}
}

public store(): void {
if (this.value === undefined) {
// Delete the key if the value is undefined
this.storage.delete(this.namespace);
} else {
// Also store false or null values
this.storage.put(this.namespace, JSON.stringify(this.value));
}
}
}

export class LoadStoreCache<V> {
private readonly namespace: string;
private readonly storage: Storage;
Expand Down
17 changes: 17 additions & 0 deletions packages/experiment-browser/src/storage/cookie-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getGlobalScope } from '@amplitude/experiment-core';

import { Storage } from '../types/storage';
export class CookieStorage implements Storage {
globalScope = getGlobalScope();
get(key: string): string {
return this.globalScope?.cookieStorage.getItem(key);
}

put(key: string, value: string): void {
this.globalScope?.cookieStorage.setItem(key, value);
}

delete(key: string): void {
this.globalScope?.cookieStorage.removeItem(key);
}
}
187 changes: 187 additions & 0 deletions packages/experiment-browser/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1575,3 +1575,190 @@ describe('flag config polling interval config', () => {
expect(client['config'].flagConfigPollingIntervalMillis).toEqual(900000);
});
});

describe('setTrackAssignmentEvent', () => {
beforeEach(async () => {
await safeGlobal.localStorage.clear();
jest.restoreAllMocks();
});

afterEach(() => {
jest.restoreAllMocks();
});

test('setTrackAssignmentEvent() no call ok', 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' },
});

// 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-js-client'),
}),
expect.objectContaining({
trackingOption: undefined,
timeoutMillis: expect.any(Number),
}),
);
});

test('setTrackAssignmentEvent() 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.setTrackAssignmentEvent(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-js-client'),
}),
expect.objectContaining({
trackingOption: 'track',
timeoutMillis: expect.any(Number),
}),
);

// Set track assignment event to false
await client.setTrackAssignmentEvent(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-js-client'),
}),
expect.objectContaining({
trackingOption: 'no-track',
timeoutMillis: expect.any(Number),
}),
);
});

test('setTrackAssignmentEvent persists the setting to storage', async () => {
const client = new ExperimentClient(API_KEY, {});

// Set track assignment event to true
await client.setTrackAssignmentEvent(true);

// Create a new client instance to verify persistence
const client2 = new ExperimentClient(API_KEY, {});

// 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-js-client'),
}),
expect.objectContaining({
trackingOption: 'track',
timeoutMillis: expect.any(Number),
}),
);
});

test('multiple calls to setTrackAssignmentEvent 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.setTrackAssignmentEvent(true);
await client.setTrackAssignmentEvent(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-js-client'),
}),
expect.objectContaining({
trackingOption: 'no-track',
timeoutMillis: expect.any(Number),
}),
);
});

test('setTrackAssignmentEvent 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.setTrackAssignmentEvent(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-js-client'),
}),
expect.objectContaining({
trackingOption: 'track',
flagKeys: ['test-flag'],
timeoutMillis: expect.any(Number),
}),
);
});
});