From 9eb9c27090256166f8092ab2d6b43bb7efbaaddc Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 24 Oct 2025 02:05:41 -0700 Subject: [PATCH 1/3] feat: add tracks assignment and exposure --- packages/node/package.json | 2 +- packages/node/src/remote/client.ts | 18 +++- packages/node/src/types/fetch.ts | 10 ++ packages/node/test/remote/client.test.ts | 117 ++++++++++++++--------- 4 files changed, 99 insertions(+), 48 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index f7442bf..c2f9391 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -33,7 +33,7 @@ "dependencies": { "@amplitude/analytics-node": "^1.3.4", "@amplitude/analytics-types": "^1.3.1", - "@amplitude/experiment-core": "^0.7.2", + "@amplitude/experiment-core": "^0.11.1", "eventsource": "^2.0.2" } } diff --git a/packages/node/src/remote/client.ts b/packages/node/src/remote/client.ts index 670a3c8..c2c6b8a 100644 --- a/packages/node/src/remote/client.ts +++ b/packages/node/src/remote/client.ts @@ -3,6 +3,7 @@ import { FetchError, SdkEvaluationApi, } from '@amplitude/experiment-core'; +import { GetVariantsOptions } from '@amplitude/experiment-core/dist/types/src/api/evaluation-api'; import { version as PACKAGE_VERSION } from '../../gen/version'; import { FetchHttpClient, WrapperClient } from '../transport/http'; @@ -107,10 +108,23 @@ export class RemoteEvaluationClient { options?: FetchOptions, ): Promise> { const userContext = this.addContext(user || {}); - const results = await this.evaluationApi.getVariants(userContext, { + const getVariantsOptions: GetVariantsOptions = { flagKeys: options?.flagKeys, timeoutMillis: timeoutMillis, - }); + }; + if (options?.tracksAssignment) { + getVariantsOptions.trackingOption = options?.tracksAssignment + ? 'track' + : 'no-track'; + } + if (options?.tracksExposure) { + (getVariantsOptions as any).exposureTrackingOption = + options?.tracksExposure ? 'track' : 'no-track'; + } + const results = await this.evaluationApi.getVariants( + userContext, + getVariantsOptions, + ); this.debug('[Experiment] Fetched variants: ', results); return evaluationVariantsToVariants(results); } diff --git a/packages/node/src/types/fetch.ts b/packages/node/src/types/fetch.ts index 4a5c966..97566e4 100644 --- a/packages/node/src/types/fetch.ts +++ b/packages/node/src/types/fetch.ts @@ -6,4 +6,14 @@ export type FetchOptions = { * Specific flag keys to evaluate and set variants for. */ flagKeys?: string[]; + + /** + * Whether to track exposure events for the request. + */ + tracksExposure?: boolean; + + /** + * Whether to track assignment events for the request. + */ + tracksAssignment?: boolean; }; diff --git a/packages/node/test/remote/client.test.ts b/packages/node/test/remote/client.test.ts index 7190e99..d7b7e08 100644 --- a/packages/node/test/remote/client.test.ts +++ b/packages/node/test/remote/client.test.ts @@ -6,59 +6,86 @@ const API_KEY = 'server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz'; const testUser: ExperimentUser = { user_id: 'test_user' }; -test('ExperimentClient.fetch, success', async () => { - const client = new RemoteEvaluationClient(API_KEY, {}); - const variants = await client.fetch(testUser); - const variant = variants['sdk-ci-test']; - delete variant.metadata; - expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); -}); +describe('ExperimentClient.fetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); -test('ExperimentClient.fetch, no retries, timeout failure', async () => { - const client = new RemoteEvaluationClient(API_KEY, { - fetchRetries: 0, - fetchTimeoutMillis: 0, + test('ExperimentClient.fetch, success', async () => { + const client = new RemoteEvaluationClient(API_KEY, {}); + const variants = await client.fetch(testUser); + const variant = variants['sdk-ci-test']; + delete variant.metadata; + expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); }); - const variants = await client.fetch(testUser); - expect(variants).toEqual({}); -}); -test('ExperimentClient.fetch, no retries, timeout failure, retry success', async () => { - const client = new RemoteEvaluationClient(API_KEY, { - fetchRetries: 1, - fetchTimeoutMillis: 0, + test('ExperimentClient.fetch, no retries, timeout failure', async () => { + const client = new RemoteEvaluationClient(API_KEY, { + fetchRetries: 0, + fetchTimeoutMillis: 0, + }); + const variants = await client.fetch(testUser); + expect(variants).toEqual({}); }); - const variants = await client.fetch(testUser); - const variant = variants['sdk-ci-test']; - delete variant.metadata; - expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); -}); -test('ExperimentClient.fetch, retry once, timeout first then succeed with 0 backoff', async () => { - const client = new RemoteEvaluationClient(API_KEY, { - fetchTimeoutMillis: 0, - fetchRetries: 1, - fetchRetryBackoffMinMillis: 0, - fetchRetryTimeoutMillis: 10_000, + test('ExperimentClient.fetch, no retries, timeout failure, retry success', async () => { + const client = new RemoteEvaluationClient(API_KEY, { + fetchRetries: 1, + fetchTimeoutMillis: 0, + }); + const variants = await client.fetch(testUser); + const variant = variants['sdk-ci-test']; + delete variant.metadata; + expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); }); - const variants = await client.fetch(testUser); - const variant = variants['sdk-ci-test']; - delete variant.metadata; - expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); -}); -test('ExperimentClient.fetch, v1 off returns undefined', async () => { - const client = new RemoteEvaluationClient(API_KEY, {}); - const variant = (await client.fetch({}))['sdk-ci-test']; - expect(variant).toBeUndefined(); -}); + test('ExperimentClient.fetch, retry once, timeout first then succeed with 0 backoff', async () => { + const client = new RemoteEvaluationClient(API_KEY, { + fetchTimeoutMillis: 0, + fetchRetries: 1, + fetchRetryBackoffMinMillis: 0, + fetchRetryTimeoutMillis: 10_000, + }); + const variants = await client.fetch(testUser); + const variant = variants['sdk-ci-test']; + delete variant.metadata; + expect(variant).toEqual({ key: 'on', value: 'on', payload: 'payload' }); + }); + + test('ExperimentClient.fetch, v1 off returns undefined', async () => { + const client = new RemoteEvaluationClient(API_KEY, {}); + const variant = (await client.fetch({}))['sdk-ci-test']; + expect(variant).toBeUndefined(); + }); -test('ExperimentClient.fetch, v2 off returns default variant', async () => { - const client = new RemoteEvaluationClient(API_KEY, {}); - const variant = (await client.fetchV2({}))['sdk-ci-test']; - expect(variant.key).toEqual('off'); - expect(variant.value).toBeUndefined(); - expect(variant.metadata.default).toEqual(true); + test('ExperimentClient.fetch, v2 off returns default variant', async () => { + const client = new RemoteEvaluationClient(API_KEY, {}); + const variant = (await client.fetchV2({}))['sdk-ci-test']; + expect(variant.key).toEqual('off'); + expect(variant.value).toBeUndefined(); + expect(variant.metadata.default).toEqual(true); + }); + + test('ExperimentClient.fetch, v2 tracksAssignment and tracksExposure', async () => { + const client = new RemoteEvaluationClient(API_KEY, {}); + const getVariantsSpy = jest.spyOn( + (client as any).evaluationApi, + 'getVariants', + ); + const variants = await client.fetchV2(testUser, { + tracksAssignment: true, + tracksExposure: true, + }); + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining(testUser), + expect.objectContaining({ + trackingOption: 'track', + exposureTrackingOption: 'track', + }), + ); + }); }); describe('ExperimentClient.fetch, retry with different response codes', () => { From ad7199491f79db327df3bdda2ecb9a770931e050 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 19 Nov 2025 15:39:00 -0800 Subject: [PATCH 2/3] docs: add comments --- packages/node/src/types/fetch.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/node/src/types/fetch.ts b/packages/node/src/types/fetch.ts index 97566e4..1692558 100644 --- a/packages/node/src/types/fetch.ts +++ b/packages/node/src/types/fetch.ts @@ -9,11 +9,13 @@ export type FetchOptions = { /** * Whether to track exposure events for the request. + * If not provided, the default is not to track exposure events. */ tracksExposure?: boolean; /** * Whether to track assignment events for the request. + * If not provided, the default is to track assignment events. */ tracksAssignment?: boolean; }; From 05479672edd32c9fd0e4aeae9a6f9c8b9b535d97 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 24 Nov 2025 15:31:35 -0800 Subject: [PATCH 3/3] feat: bump experiment-core version --- packages/node/package.json | 2 +- packages/node/test/remote/client.test.ts | 18 ++++++++++++++++++ yarn.lock | 8 ++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index 63cb5eb..d542c04 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -33,7 +33,7 @@ "dependencies": { "@amplitude/analytics-node": "^1.3.4", "@amplitude/analytics-types": "^1.3.1", - "@amplitude/experiment-core": "^0.11.1", + "@amplitude/experiment-core": "0.12.0", "eventsource": "^2.0.2" } } diff --git a/packages/node/test/remote/client.test.ts b/packages/node/test/remote/client.test.ts index d7b7e08..f541100 100644 --- a/packages/node/test/remote/client.test.ts +++ b/packages/node/test/remote/client.test.ts @@ -68,6 +68,23 @@ describe('ExperimentClient.fetch', () => { expect(variant.metadata.default).toEqual(true); }); + test('ExperimentClient.fetch, v2 no tracksAssignment and no tracksExposure', async () => { + const client = new RemoteEvaluationClient(API_KEY, {}); + const getVariantsSpy = jest.spyOn( + (client as any).evaluationApi, + 'getVariants', + ); + const variants = await client.fetchV2(testUser); + expect(variants['sdk-ci-test'].key).toEqual('on'); + expect(getVariantsSpy).toHaveBeenCalledWith( + expect.objectContaining(testUser), + expect.not.objectContaining({ + trackingOption: expect.any(String), + exposureTrackingOption: expect.any(String), + }), + ); + }); + test('ExperimentClient.fetch, v2 tracksAssignment and tracksExposure', async () => { const client = new RemoteEvaluationClient(API_KEY, {}); const getVariantsSpy = jest.spyOn( @@ -78,6 +95,7 @@ describe('ExperimentClient.fetch', () => { tracksAssignment: true, tracksExposure: true, }); + expect(variants['sdk-ci-test'].key).toEqual('on'); expect(getVariantsSpy).toHaveBeenCalledWith( expect.objectContaining(testUser), expect.objectContaining({ diff --git a/yarn.lock b/yarn.lock index a5cf615..eeaeeae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36,10 +36,10 @@ resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-1.3.3.tgz#c7b2a21e6ab0eb1670cce4d03127b62c373c6ed4" integrity sha512-V4/h+izhG7NyVfIva1uhe6bToI/l5n+UnEomL3KEO9DkFoKiOG7KmXo/fmzfU6UmD1bUEWmy//hUFF16BfrEww== -"@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.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@amplitude/experiment-core/-/experiment-core-0.12.0.tgz#9a2ee4c054da6dd629bbabbcbbf20188cdece3dd" + integrity sha512-EiLLxcyJD8T3GFsMPxBfWx9n9fBw6rC0RJwccPXLzResE0HnGZZpVWF86ZndnYmEMD1lUUjWi41N1ymEzodI5w== dependencies: js-base64 "^3.7.5"