Skip to content

Commit 79e230b

Browse files
authored
feat: add setTracksAssignment (#224)
* feat: add setTrackAssignmentEvent * fix: lint * fix: add test and explicit default value * fix: undefined handling * fix: rm cookie storage * feat: rename setTracksAssignment * fix: comment * fix: lint
1 parent 4f30a57 commit 79e230b

File tree

5 files changed

+272
-5
lines changed

5 files changed

+272
-5
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ dist/
2727

2828
# Example Experiment tag script
2929
packages/experiment-tag/example/
30+
31+
# dotenv files
32+
.env*

packages/experiment-browser/src/experimentClient.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
TimeoutError,
1616
topologicalSort,
1717
getGlobalScope,
18+
GetVariantsOptions,
1819
} from '@amplitude/experiment-core';
1920

2021
import { version as PACKAGE_VERSION } from '../package.json';
@@ -25,8 +26,10 @@ import { AmpLogger } from './logger/ampLogger';
2526
import { ConsoleLogger } from './logger/consoleLogger';
2627
import {
2728
getFlagStorage,
29+
getVariantsOptionsStorage,
2830
getVariantStorage,
2931
LoadStoreCache,
32+
SingleValueStoreCache,
3033
transformVariantFromStorage,
3134
} from './storage/cache';
3235
import { LocalStorage } from './storage/local-storage';
@@ -93,6 +96,7 @@ export class ExperimentClient implements Client {
9396
private readonly integrationManager: IntegrationManager;
9497
// Web experiment adds a user to the flags request
9598
private readonly isWebExperiment: boolean;
99+
private readonly fetchVariantsOptions: SingleValueStoreCache<GetVariantsOptions>;
96100

97101
// Deprecated
98102
private analyticsProvider: SessionAnalyticsProvider | undefined;
@@ -194,9 +198,15 @@ export class ExperimentClient implements Client {
194198
storage,
195199
);
196200
this.flags = getFlagStorage(this.apiKey, storageInstanceName, storage);
201+
this.fetchVariantsOptions = getVariantsOptionsStorage(
202+
this.apiKey,
203+
storageInstanceName,
204+
storage,
205+
);
197206
try {
198207
this.flags.load();
199208
this.variants.load();
209+
this.fetchVariantsOptions.load();
200210
} catch (e) {
201211
// catch localStorage undefined error
202212
}
@@ -712,7 +722,10 @@ export class ExperimentClient implements Client {
712722
}
713723

714724
try {
715-
const variants = await this.doFetch(user, timeoutMillis, options);
725+
const variants = await this.doFetch(user, timeoutMillis, {
726+
trackingOption: this.fetchVariantsOptions.get()?.trackingOption,
727+
...options,
728+
});
716729
await this.storeVariants(variants, options);
717730
return variants;
718731
} catch (e) {
@@ -723,6 +736,18 @@ export class ExperimentClient implements Client {
723736
}
724737
}
725738

739+
/**
740+
* Enables or disables tracking of assignment events when fetching variants.
741+
* @param doTrack Whether to track assignment events.
742+
*/
743+
public async setTracksAssignment(doTrack: boolean): Promise<void> {
744+
this.fetchVariantsOptions.put({
745+
...(this.fetchVariantsOptions.get() || {}),
746+
trackingOption: doTrack ? 'track' : 'no-track',
747+
});
748+
this.fetchVariantsOptions.store();
749+
}
750+
726751
private cleanUserPropsForFetch(user: ExperimentUser): ExperimentUser {
727752
const cleanedUser = { ...user };
728753
delete cleanedUser.cookie;
@@ -732,7 +757,7 @@ export class ExperimentClient implements Client {
732757
private async doFetch(
733758
user: ExperimentUser,
734759
timeoutMillis: number,
735-
options?: FetchOptions,
760+
options?: GetVariantsOptions,
736761
): Promise<Variants> {
737762
user = await this.addContextOrWait(user);
738763
user = this.cleanUserPropsForFetch(user);

packages/experiment-browser/src/storage/cache.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EvaluationFlag } from '@amplitude/experiment-core';
1+
import { EvaluationFlag, GetVariantsOptions } from '@amplitude/experiment-core';
22

33
import { Storage } from '../types/storage';
44
import { Variant } from '../types/variant';
@@ -29,6 +29,52 @@ export const getFlagStorage = (
2929
return new LoadStoreCache<EvaluationFlag>(namespace, storage);
3030
};
3131

32+
export const getVariantsOptionsStorage = (
33+
deploymentKey: string,
34+
instanceName: string,
35+
storage: Storage = new LocalStorage(),
36+
): SingleValueStoreCache<GetVariantsOptions> => {
37+
const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6);
38+
const namespace = `amp-exp-${instanceName}-${truncatedDeployment}-variants-options`;
39+
return new SingleValueStoreCache<GetVariantsOptions>(namespace, storage);
40+
};
41+
42+
export class SingleValueStoreCache<V> {
43+
private readonly namespace: string;
44+
private readonly storage: Storage;
45+
private value: V | undefined;
46+
47+
constructor(namespace: string, storage: Storage) {
48+
this.namespace = namespace;
49+
this.storage = storage;
50+
}
51+
52+
public get(): V | undefined {
53+
return this.value;
54+
}
55+
56+
public put(value: V): void {
57+
this.value = value;
58+
}
59+
60+
public load(): void {
61+
const value = this.storage.get(this.namespace);
62+
if (value) {
63+
this.value = JSON.parse(value);
64+
}
65+
}
66+
67+
public store(): void {
68+
if (this.value === undefined) {
69+
// Delete the key if the value is undefined
70+
this.storage.delete(this.namespace);
71+
} else {
72+
// Also store false or null values
73+
this.storage.put(this.namespace, JSON.stringify(this.value));
74+
}
75+
}
76+
}
77+
3278
export class LoadStoreCache<V> {
3379
private readonly namespace: string;
3480
private readonly storage: Storage;

packages/experiment-browser/test/client.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,3 +1575,190 @@ describe('flag config polling interval config', () => {
15751575
expect(client['config'].flagConfigPollingIntervalMillis).toEqual(900000);
15761576
});
15771577
});
1578+
1579+
describe('setTracksAssignment', () => {
1580+
beforeEach(async () => {
1581+
await safeGlobal.localStorage.clear();
1582+
jest.restoreAllMocks();
1583+
});
1584+
1585+
afterEach(() => {
1586+
jest.restoreAllMocks();
1587+
});
1588+
1589+
test('setTracksAssignment() no call ok', async () => {
1590+
const client = new ExperimentClient(API_KEY, {});
1591+
1592+
// Mock the evaluationApi.getVariants method
1593+
const getVariantsSpy = jest.spyOn(
1594+
(client as any).evaluationApi,
1595+
'getVariants',
1596+
);
1597+
getVariantsSpy.mockResolvedValue({
1598+
'test-flag': { key: 'on', value: 'on' },
1599+
});
1600+
1601+
// Fetch variants to trigger the API call
1602+
await client.fetch(testUser);
1603+
1604+
// Verify getVariants was called with trackingOption: 'track'
1605+
expect(getVariantsSpy).toHaveBeenCalledWith(
1606+
expect.objectContaining({
1607+
user_id: testUser.user_id,
1608+
library: expect.stringContaining('experiment-js-client'),
1609+
}),
1610+
expect.objectContaining({
1611+
trackingOption: undefined,
1612+
timeoutMillis: expect.any(Number),
1613+
}),
1614+
);
1615+
});
1616+
1617+
test('setTracksAssignment() sets trackingOption to track and getVariants is called with correct options', async () => {
1618+
const client = new ExperimentClient(API_KEY, {});
1619+
1620+
// Mock the evaluationApi.getVariants method
1621+
const getVariantsSpy = jest.spyOn(
1622+
(client as any).evaluationApi,
1623+
'getVariants',
1624+
);
1625+
getVariantsSpy.mockResolvedValue({
1626+
'test-flag': { key: 'on', value: 'on' },
1627+
});
1628+
1629+
// Set track assignment event to true
1630+
await client.setTracksAssignment(true);
1631+
1632+
// Fetch variants to trigger the API call
1633+
await client.fetch(testUser);
1634+
1635+
// Verify getVariants was called with trackingOption: 'track'
1636+
expect(getVariantsSpy).toHaveBeenCalledWith(
1637+
expect.objectContaining({
1638+
user_id: testUser.user_id,
1639+
library: expect.stringContaining('experiment-js-client'),
1640+
}),
1641+
expect.objectContaining({
1642+
trackingOption: 'track',
1643+
timeoutMillis: expect.any(Number),
1644+
}),
1645+
);
1646+
1647+
// Set track assignment event to false
1648+
await client.setTracksAssignment(false);
1649+
1650+
// Fetch variants to trigger the API call
1651+
await client.fetch(testUser);
1652+
1653+
// Verify getVariants was called with trackingOption: 'no-track'
1654+
expect(getVariantsSpy).toHaveBeenCalledWith(
1655+
expect.objectContaining({
1656+
user_id: testUser.user_id,
1657+
library: expect.stringContaining('experiment-js-client'),
1658+
}),
1659+
expect.objectContaining({
1660+
trackingOption: 'no-track',
1661+
timeoutMillis: expect.any(Number),
1662+
}),
1663+
);
1664+
});
1665+
1666+
test('setTracksAssignment persists the setting to storage', async () => {
1667+
const client = new ExperimentClient(API_KEY, {});
1668+
1669+
// Set track assignment event to true
1670+
await client.setTracksAssignment(true);
1671+
1672+
// Create a new client instance to verify persistence
1673+
const client2 = new ExperimentClient(API_KEY, {});
1674+
1675+
// Mock the evaluationApi.getVariants method for the second client
1676+
const getVariantsSpy = jest.spyOn(
1677+
(client2 as any).evaluationApi,
1678+
'getVariants',
1679+
);
1680+
getVariantsSpy.mockResolvedValue({
1681+
'test-flag': { key: 'on', value: 'on' },
1682+
});
1683+
1684+
// Fetch variants with the second client
1685+
await client2.fetch(testUser);
1686+
1687+
// Verify the setting was persisted and loaded by the second client
1688+
expect(getVariantsSpy).toHaveBeenCalledWith(
1689+
expect.objectContaining({
1690+
user_id: testUser.user_id,
1691+
library: expect.stringContaining('experiment-js-client'),
1692+
}),
1693+
expect.objectContaining({
1694+
trackingOption: 'track',
1695+
timeoutMillis: expect.any(Number),
1696+
}),
1697+
);
1698+
});
1699+
1700+
test('multiple calls to setTracksAssignment uses the latest setting', async () => {
1701+
const client = new ExperimentClient(API_KEY, {});
1702+
1703+
// Mock the evaluationApi.getVariants method
1704+
const getVariantsSpy = jest.spyOn(
1705+
(client as any).evaluationApi,
1706+
'getVariants',
1707+
);
1708+
getVariantsSpy.mockResolvedValue({
1709+
'test-flag': { key: 'off', value: 'off' },
1710+
});
1711+
1712+
// Set track assignment event to true, then false
1713+
await client.setTracksAssignment(true);
1714+
await client.setTracksAssignment(false);
1715+
1716+
// Fetch variants to trigger the API call
1717+
await client.fetch(testUser);
1718+
1719+
// Verify getVariants was called with the latest setting (no-track)
1720+
expect(getVariantsSpy).toHaveBeenCalledWith(
1721+
expect.objectContaining({
1722+
user_id: testUser.user_id,
1723+
library: expect.stringContaining('experiment-js-client'),
1724+
}),
1725+
expect.objectContaining({
1726+
trackingOption: 'no-track',
1727+
timeoutMillis: expect.any(Number),
1728+
}),
1729+
);
1730+
});
1731+
1732+
test('setTracksAssignment preserves other existing options while updating trackingOption', async () => {
1733+
const client = new ExperimentClient(API_KEY, {});
1734+
1735+
// Mock the evaluationApi.getVariants method
1736+
const getVariantsSpy = jest.spyOn(
1737+
(client as any).evaluationApi,
1738+
'getVariants',
1739+
);
1740+
getVariantsSpy.mockResolvedValue({
1741+
'test-flag': { key: 'on', value: 'on' },
1742+
});
1743+
1744+
// Set track assignment event to true
1745+
await client.setTracksAssignment(true);
1746+
1747+
// Fetch variants with specific flag keys to ensure other options are preserved
1748+
const fetchOptions = { flagKeys: ['test-flag'] };
1749+
await client.fetch(testUser, fetchOptions);
1750+
1751+
// Verify getVariants was called with both trackingOption and flagKeys
1752+
expect(getVariantsSpy).toHaveBeenCalledWith(
1753+
expect.objectContaining({
1754+
user_id: testUser.user_id,
1755+
library: expect.stringContaining('experiment-js-client'),
1756+
}),
1757+
expect.objectContaining({
1758+
trackingOption: 'track',
1759+
flagKeys: ['test-flag'],
1760+
timeoutMillis: expect.any(Number),
1761+
}),
1762+
);
1763+
});
1764+
});

packages/experiment-tag/rollup.config.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import * as packageJson from './package.json';
1616

1717
let branchName = '';
1818
try {
19-
const fullBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
19+
const fullBranch = execSync('git rev-parse --abbrev-ref HEAD', {
20+
encoding: 'utf8',
21+
}).trim();
2022
const cleanBranch = fullBranch.replace(/^web\//, '');
2123
branchName = cleanBranch !== 'main' ? cleanBranch : '';
2224
} catch (error) {
@@ -64,7 +66,11 @@ const getOutputConfig = (outputOptions) => ({
6466
output: {
6567
dir: 'dist',
6668
name: 'WebExperiment',
67-
banner: `/* ${packageJson.name} v${packageJson.version}${branchName ? ` (${branchName})` : ''} - For license info see https://unpkg.com/@amplitude/experiment-tag@${packageJson.version}/files/LICENSE */`,
69+
banner: `/* ${packageJson.name} v${packageJson.version}${
70+
branchName ? ` (${branchName})` : ''
71+
} - For license info see https://unpkg.com/@amplitude/experiment-tag@${
72+
packageJson.version
73+
}/files/LICENSE */`,
6874
...outputOptions,
6975
},
7076
});

0 commit comments

Comments
 (0)